Ver licença de uso para detalhes.

Prefácio

Esse tutorial visa apresentar alguns conceitos de processamento digital de imagens usando a biblioteca de visão artificial OpenCV. Foi concebido como material acessório da disciplina processamento digital de imagens e, neste contexto, assume que o leitor possui fundamentação teórica suficiente para acompanhar as lições. Dominar adequadamente conceitos de programação em C++ e da matemática explorada em cursos de Análise de Sinais e Sistemas são requisitos importantes para entender adequadamente algumas lições.

Toda e qualquer sugestão e/ou contribuição visando melhorar e evoluir este tutorial será bemvinda. Pode mandá-la diretamente via e-mail para ambj@dca.ufrn.br

Os exemplos descritos no tutorial foram desenvolvidos usando a API C++ do OpenCV. Foram testados em um ambiente executando sistema operacional Linux, mas devem funcionar corretamente em outras plataformas.

Parte I: Processamento de Imagens no Domínio Espacial

1. Conceitos iniciais

1.1. O que é OpenCV

OpenCV (Open Source Computer Vision Library: http://www.opencv.org) é uma biblioteca (ou conjunto de bibliotecas) disponível para algumas linguagens de programação que visa oferecer um vasto ferramental para tratamento de imagens, visão computacional e reconhecimento de padrões.

A biblioteca é organizada na forma de módulos, cada um agregando um conjunto de funções. Entre os módulos disponíveis pela biblioteca, destacam-se:

  • Estruturas de núcleo, como tipos de dados comuns, matrizes e vetores, para armazenamento de informações de forma conveniente para os demais módulos.

  • Processamento de imagens, para realizar transformações geométricas, filtragem linear e não linear, tratamento de cor, entre outras coisas.

  • Vídeo, para avaliação de movimento (ex: análise de fluxo ótico) e rastreio de objetos.

  • Calibração de câmeras.

  • Extração de características.

  • Deteção de objetos.

  • Highgui, para tratamento facilitado de criação de interfaces gráficas.

Neste tutorial, alguns desses módulos serão explorados na forma de exemplos e exercícios de fixação.

Detalhes acerca da instalação da biblioteca OpenCV não são descritos aqui. Cada sistema operacional possui uma forma de instalação distinta, que pode ser consultatada no quickstart oferecido no website do projeto.

1.2. Hello, OpenCV

O primeiro exemplo que será apresentado visa mostrar como compilar e executar um pequeno usando OpenCV em um ambiente Linux. Em suma, será necessário o arquivo contendo o código-fonte a ser compilado e um arquivo contendo regras de compilação na forma de um Makefile.

As tarefas de compilação foram automatizadas com o utilitário make. O make determina automaticamente que partes de um grande programa necessitam ser recompiladas e os comandos necessários para recompilá-las, a partir da leitura das regras definidas em um arquivo Makefile. Assim, para efetuar a compilação do programa, basta executar o comando make, ao invés de digitar dezenas de comandos no prompt do Unix.

O arquivo da Listagem 1 da foi utilizado para compilar os exemplos desse curso. Para obter uma cópia deste arquivo clique aqui. Veja que o arquivo utilizado prevê o uso da biblioteca opencv 4.0. Caso esteja utilizando alguma biblioteca mais antiga, subsititua o parâmetro opencv4 por apenas opencv e repita a compilação

Listagem 1. Makefile
.SUFFIXES:
.SUFFIXES: .cpp

GCC = g++

.cpp:
	$(GCC) -Wall -Wunused -std=c++11 -O2 $< -o $@ `pkg-config --cflags --libs opencv4`

As regras contidas neste arquivo Makefile incluem opções de compilação para incluir as dependências da biblioteca OpenCV para o programa que será compilado. Este Makefile possibilita que programas simples possam ser facilmente compilados. Como foi testado em um sistema Linux, o arquivo poderá carecer de modificações em outros sistemas.

O seu ambiente de desenvolvimento poderá ser testado com programa exemplos/hello.cpp , mostrada Listagem Hello, cuja única funcionalidade apresentar na tela uma imagem fornecida via linha de comando.

Listagem 2. hello.cpp
#include <iostream>
#include <opencv2/opencv.hpp>

int main(int argc, char** argv){
  cv::Mat image;
  image = cv::imread(argv[1],cv::IMREAD_GRAYSCALE);
  cv::imshow("image", image);
  cv::waitKey();
  return 0;
}

Para compilar e executar o programa hello.cpp, salve-o juntamente com o arquivo Makefile e a imagem biel.png em um diretório e execute a seguinte seqüência de comandos:

$ make hello
$ ./hello biel.png

A saída do programa hello é mostrado na Figura 1

Saida do programa hello
Figura 1. Saída do programa hello

Caso o programa funcione conforme o exemplo, provavelmente seu ambiente de testes estará operacional para os exemplos do tutorial.

Neste tutorial, geralmente imagens armazenada no formato PNG serão usadas. Este formato suporta representação de imagens de diversas formas, como em tons de cinza, coloridas e preto-e-branco. Imagens em outros formatos tais como JPEG podem oferecer algumas limitações para os casos que serão abordados. Por exemplo, arquivos JPEG armazenam apenas imagens em formato colorido e, durante a o processo de gravação das imagens, o algoritmo de compressão com perdas pode modificar o conteúdo da imagem original a ser gravada.

2. Manipulando pixels em uma imagem

O objetivo dessa lição é mostrar como manipular os pixels de uma imagem, mudando a cor de uma pequena região retangular de uma imagem fornecida para processamento.

Alguns conceitos importantes serão abordados neste contexto:

  • Alguns tipos de dados comuns mais usados no OpenCV.

  • Funções para realizar entrada/saída de dados.

  • Funções para acessar os pixels de uma imagem.

  • Funções para realizar interações na interface gráfica.

Para evoluir nesses conceitos, realize o download do programa pixels.cpp, mostrado na Listagem Pixels, e a imagem bolhas.png. Salve ambos os arquivos num diretório, juntamente que contém arquivo Makefile. O programa irá abrir a imagem bolhas.png (interpretando-a em escala de cinza), deverá exibi-la em uma janela e desenhar um quadrado preto em uma região pré-estabelecida.

Após isso, ele irá aguardar que o usuário pressione alguma tecla. Uma vez pressionada a tecla, o programa reabrirá o arquivo da imagem interpretando-a em escala de cores e passará a desenhar um quadrado vermelho na mesma região que foi pré-estabelecida.

Listagem 3. pixels.cpp
#include <iostream>
#include <opencv2/opencv.hpp>

int main(int, char**){
  cv::Mat image;
  cv::Vec3b val;

  image= cv::imread("bolhas.png",cv::IMREAD_GRAYSCALE);
  if(!image.data)
    std::cout << "nao abriu bolhas.png" << std::endl;

  cv::namedWindow("janela", cv::WINDOW_AUTOSIZE);

  for(int i=200;i<210;i++){
    for(int j=10;j<200;j++){
      image.at<uchar>(i,j)=0;
    }
  }
  
  cv::imshow("janela", image);  
  cv::waitKey();

  image= cv::imread("bolhas.png",cv::IMREAD_COLOR);

  val[0] = 0;   //B
  val[1] = 0;   //G
  val[2] = 255; //R
  
  for(int i=200;i<210;i++){
    for(int j=10;j<200;j++){
      image.at<cv::Vec3b>(i,j)=val;
    }
  }

  cv::imshow("janela", image);  
  cv::waitKey();
  return 0;
}

Para compilar e executar o programa pixels.cpp, salve-o juntamente com o arquivo Makefile em um diretório e execute a seguinte seqüência de comandos:

$ make pixels
$ ./pixels

A saída do programa pixels é mostrado na Figura 2

Pixels
Figura 2. Saída do programa pixels

2.1. Descrição do programa pixels.cpp

#include <iostream>
#include <opencv2/opencv.hpp>

Em geral, a maior parte das funcionalidades da biblioteca é definida no arquivo de cabeçalho opencv.hpp. É necessário procurar na árvore do compilador usado este arquivo para que seu caminho seja incluído no código.

Nesse arquivo, são definidos tipos básicos da biblioteca, bem como os protótipos de várias funções usadas para tratamento de imagens. Ele agrega as definições de funções que são usadas para entrada e saída de dados, criação de e manipulação de janelas e seus eventos, além de widgets para permitir interação com o usuário.

cv::Mat image;

A interface em C++ do OpenCV provê um tipo básico de estrutura para armazenar imagems: a classe Mat. Dependendo da forma como é criado, um objeto dessa classe é capaz de armazenar imagens (matrizes) de diversos tipos diferentes, tais como inteiros, floats, doubles, etc.

Dezenas de métodos são providos para essa classe, métodos estes que serão apresentados ao longo do tutorial, conforme a demanda se dê. Nesta lição, apenas o método at será utilizado.

Outros tipos também são predefinidos no OpenCV, tal como o tipo Vec3b. A classe Vec é definida no OpenCV para abrigar diversas formas de vetores curtos. A classe é definida por gabaritos e provê armazenamento de uma quantidade de valores de um dado tipo fornecido na instanciação do gabarito.

Para ilustrar o uso da classe Vec, são mostrados em seguida, alguns dos tipos predefinidos internamente no OpenCV.

typedef Vec<uchar, 2> Vec2b;
typedef Vec<uchar, 3> Vec3b;
typedef Vec<uchar, 4> Vec4b;

typedef Vec<short, 2> Vec2s;
typedef Vec<short, 3> Vec3s;
typedef Vec<short, 4> Vec4s;

typedef Vec<int, 2> Vec2i;
typedef Vec<int, 3> Vec3i;
typedef Vec<int, 4> Vec4i;

typedef Vec<float, 2> Vec2f;
typedef Vec<float, 3> Vec3f;
typedef Vec<float, 4> Vec4f;
typedef Vec<float, 6> Vec6f;

typedef Vec<double, 2> Vec2d;
typedef Vec<double, 3> Vec3d;
typedef Vec<double, 4> Vec4d;
typedef Vec<double, 6> Vec6d;

Neste caso, o tipo Vec3b usado no exemplo representa um vetor de três componentes, cada uma do tipo unsigned char, ocupando apenas um byte na memória.

image= cv::imread("bolhas.png",cv::IMREAD_GRAYSCALE);
if(!image.data)
  std::cout << "nao abriu bolhas.png" << std::endl;

Esse trecho de código usa a função imread() para ler uma imagem presente em um arquivo e armazená-la no objeto image. A função imread() recebe dois parâmetros: o primeiro é o caminho para o arquivo a ser aberto; o segundo é a forma como a imagem será interpretada. Neste casso, independentemente do formato da imagem presente no arquivo bolhas.png, ela será imediatamente transformada em uma imagem em tons de cinza antes de ser guardada no objeto image.

Caso o arquivo não seja aberto (ex: o arquivo não foi encontrado, ou o usuário não possui permissão de leitura ativado), o campo data do objeto image deverá ter valor nulo, sinalizando o erro.

Algo importante a ser observado nesse momento é que, embora a estrutura para armazenamento de imagens seja provida por uma classe - a classe Mat - algumas das estruturas internas são públicas. Isso ajuda a não haver degradação de desempenho com o uso excessivo de chamadas de métodos.

std::namedWindow("janela", cv::WINDOW_AUTOSIZE);

Cria uma janela para que o usuário possa referenciá-la pelo nome que é fornecido. O parâmetro WINDOW_AUTOSIZE permitirá que a janela se ajuste automaticamente para o tamanho da imagen que for fornecida para exibição.

for(int i=200;i<210;i++){
  for(int j=10;j<200;j++){
    image.at<uchar>(i,j)=0;
  }
}

Este trecho de código desenha um retângulo preto na imagem. Neste caso, assumindo que a primeira dimensão representa a coordenada x e a segunda dimensão da matriz representa a coordenada y, será desenhado um retângulo do ponto \$(200,10)\$ até o ponto \$(210,200)\$.

Os pixels da região são acessados ou modificados com o método at. Observe que este método sofre o efeito de um gabarito, que deve receber um tipo correspondente o tipo de dado que está armazenado no objeto image. Como a leitura foi feita assumindo uma imagem em tons de cinza, o tipo de dado necessário será, neste caso, unsigned char. Tipos diferentes poderão gerar resultados incorretos, posto que a função at() interpretará a sequência de bytes da matriz image de forma inapropriada.

Para manter os sistema referencial do tipo destrógiro, assume-se que os eixos x e y ficarão organizados conforme apresenta a Figura 3, com a origem no canto superior esquerdo da imagem.

eixos
Figura 3. Sistema referencial adotado no OpenCV.
cv::imshow("janela", image);
cv::waitKey();

A imagem image é mostrada na janela "janela" e o programa aguarda até que o usuário digite alguma tecla.

image= cv::imread("bolhas.png", cv::IMREAD_COLOR);

O procedimento que segue repete os passos anteriores, só que agora a imagem interpretada com três componentes de cor. A matriz image agora guardará, para cada pixel, um conjunto de 3 bytes para armazenar as contribuições de vermelho, verde e amarelo que este possui.

val[0] = 0;   //B
val[1] = 0;   //G
val[2] = 255; //R

As cores em um pixel são ordenadas na sequência B→G→R (Azul, Verde, Vermelho), podendo os pixels da matriz serem interpretados como um conjunto de elementos do tipo Vec3b.

for(int i=200;i<210;i++){
  for(int j=10;j<200;j++){
    image.at<Vec3b>(i,j)=val;
  }
}

Agora, a função at() interpreta a sequência de bytes usada para armazenar a matriz image como sendo formadas por pixels do tipo Vec3b, sendo às posições correspondentes atribuídas a cor vermelha predefinida na variável val.

2.2. Exercícios

  • Utilizando o programa exemplos/pixels.cpp como referência, implemente um programa regions.cpp. Esse programa deverá solicitar ao usuário as coordenadas de dois pontos \$P_1\$ e \$P_2\$ localizados dentro dos limites do tamanho da imagem e exibir que lhe for fornecida. Entretanto, a região definida pelo retângulo de vértices opostos definidos pelos pontos \$P_1\$ e \$P_2\$ será exibida com o negativo da imagem na região correspondente. O efeito é ilustrado na Figura 4.

regions
Figura 4. Exemplo de saída do programa regions.cpp
  • Utilizando o programa exemplos/pixels.cpp como referência, implemente um programa trocaregioes.cpp. Seu programa deverá trocar os quadrantes em diagonal na imagem. Explore o uso da classe Mat e seus construtores para criar as regiões que serão trocadas. O efeito é ilustrado na Figura 5.

trocaregioes
Figura 5. Exemplo de saída do programa trocaregioes.cpp

3. Serialização de dados em ponto flutuante via FileStorage

Nem todo tipo de imagem pode ser armazenado em arquivos com formatos comuns como JPEG ou PNG. Esses formatos suportam apenas imagens convencionais, cujo tipo de dado associado ao pixel normalmente é um unsigned char. Entretanto, em muitos casos, é necessário armazenar imagens com dados de ponto flutuante (float ou double), como por exemplo, imagens de alta dinâmica, imagens HDR, imagens de profundidade, máscaras usadas em filtros digitais, funções de transferência usadas para filtragem no domínio da frequência etc. Para isso, o OpenCV disponibiliza a classe cv::FileStorage, que permite armazenar dados em arquivos com formatos mais genéricos, como XML ou YAML. Essa classe é muito útil para armazenar dados de forma estruturada, como por exemplo, dados de uma imagem, como largura, altura, número de canais, tipo de dado, etc. Assim, matrizes representadas em ponto flutuante podem ser guardadas para uso posterior.

Nesta lição, será mostrado como armazenar e recuperar dados em ponto flutuante em um arquivo codificado em YAML. Para isso, será criada uma matriz de pixels do tipo float e armazenada em um arquivo YAML. Em seguida, será recuperada a matriz do arquivo YAML, processada e exibida na tela.

Para criar a matriz de float e armazená-la em um arquivo, realize o download do programa filestorage.cpp, mostrado na Listagem 4.

Listagem 4. filestorage.cpp
#include <iostream>
#include <opencv2/opencv.hpp>
#include <sstream>
#include <string>

int SIDE = 256;
int PERIODOS = 8;

int main(int argc, char** argv) {
  std::stringstream ss_img, ss_yml;
  cv::Mat image;

  ss_yml << "senoide-" << SIDE << ".yml";
  image = cv::Mat::zeros(SIDE, SIDE, CV_32FC1);

  cv::FileStorage fs(ss_yml.str(), cv::FileStorage::WRITE);

  for (int i = 0; i < SIDE; i++) {
    for (int j = 0; j < SIDE; j++) {
      image.at<float>(i, j) = 127 * sin(2 * M_PI * PERIODOS * j / SIDE) + 128;
    }
  }

  fs << "mat" << image;
  fs.release();

  cv::normalize(image, image, 0, 255, cv::NORM_MINMAX);
  image.convertTo(image, CV_8U);
  ss_img << "senoide-" << SIDE << ".png";
  cv::imwrite(ss_img.str(), image);

  fs.open(ss_yml.str(), cv::FileStorage::READ);
  fs["mat"] >> image;

  cv::normalize(image, image, 0, 255, cv::NORM_MINMAX);
  image.convertTo(image, CV_8U);

  cv::imshow("image", image);
  cv::waitKey();

  return 0;
}

Para compilar e executar o programa filestorage.cpp, salve-o juntamente com o arquivo Makefile em um diretório e execute a seguinte seqüência de comandos:

$ make filestorage
$ ./filestorage

A saída do programa filestorage é mostrado na Figura 6

filestorage
Figura 6. Saída do programa filestorage

3.1. Descrição do programa filestorage.cpp

ss_yml << "senoide-" << SIDE << ".yml";
image = cv::Mat::zeros(SIDE, SIDE, CV_32FC1);
cv::FileStorage fs(ss_yml.str(), cv::FileStorage::WRITE);

A primeira linha usa a classe stringstream para criar um string com o nome do arquivo de saída. O nome do arquivo é formado pela concatenação da string "senoide-" com o valor da constante SIDE e a extensão ".yml". Perceba que é possível alterar o arquivo de saída para se amoldar ao tamanho desejado para o lado da imagem. A segunda linha cria uma matriz de float de tamanho SIDE x SIDE e inicializa todos os elementos com o valor 0. O tipo CV_32FC1 representa um dado em OpenCV de 32 bits em ponto flutuante com apenas um canal (o equivalente ao tipo float). A terceira linha cria um objeto da classe FileStorage para armazenar dados em um arquivo. O primeiro parâmetro é o nome do arquivo de saída, o segundo parâmetro é o modo de abertura do arquivo. Neste caso, o modo de abertura é cv::FileStorage::WRITE, o que indica que o arquivo será aberto para escrita, permitindo a gravação da imagem gerada em ponto flutuante.

for (int i = 0; i < SIDE; i++) {
  for (int j = 0; j < SIDE; j++) {
    image.at<float>(i, j) = 127 * sin(2 * M_PI * FREQUENCIA * j / SIDE) + 128;
  }
}

O laço aninhado percorre todos os elementos da matriz image e atribui a cada elemento um valor de brilho correspondente à amplitude da senóide naquele ponto. Perceba que na imagem gerada a senóide percorre um total de 8 períodos ao longo de cada linha, o que equivale exatamente ao valor da variável FREQUENCIA. O valor da senoide é multiplicado por 127 e somado a 128 para que o valor da senóide fique entre 0 e 255.

fs << "mat" << image;
fs.release();

O objeto fs recebe a serialização dos dados da matriz image associados com o identificador literal "mat". Durante o processo de deserialização, o identificador literal "mat" será usado para recuperar os dados da matriz image. O método release() fecha o arquivo de saída. As linhas mostradas na Listagem 5 são extraídas do início do arquivo senoide-256.yml que é gerado pelo programa filestorage.cpp. Perceba que o arquivo codificado em YAML é escrito em texto simples, composto por uma sequência de pares chave-valor, onde a chave é o identificador literal "mat" e o valor é a matriz de float serializada.

Listagem 5. trecho do arquivo senoide-256.yml
%YAML:1.0
---
mat: !!opencv-matrix
   rows: 256
   cols: 256
   dt: f
   data: [ 128., 1.52776474e+02, 1.76600800e+02, 1.98557419e+02,
       2.17802567e+02, 2.33596634e+02, 2.45332703e+02, 2.52559738e+02,
       255., 2.52559738e+02, 2.45332703e+02, 2.33596634e+02,
       2.17802567e+02, 1.98557419e+02, 1.76600800e+02, 1.52776474e+02,
cv::normalize(image, image, 0, 255, cv::NORM_MINMAX);
image.convertTo(image, CV_8U);
ss_img << "senoide-" << SIDE << ".png";
cv::imwrite(ss_img.str(), image);

A mesma imagem é também gravada no formato PNG, para que possa ser visualizada. Para isso, é necessário converter a matriz de float para o tipo CV_8U, que representa um dado em OpenCV de 8 bits sem sinal com apenas um canal (o equivalente ao tipo unsigned char). O método normalize() é usado para normalizar os valores da matriz de float para o intervalo \$0, 255\$. O método convertTo() converte a matriz de float para o tipo CV_8U para que a gravação no arquivo determinado se dê sem intercorrências. Perceba que a classe stringstream foi novamente usada para proceder com a criação do nome do arquivo. A figura Figura 7 mostra a imagem gerada pelo programa

senoide256
Figura 7. Imagem da senoide gerada pelo programa filestorage.cpp

3.2. Exercícios

  • Utilizando o programa filestorage.cpp como base, crie um programa que gere uma imagem de dimensões 256x256 pixels contendo uma senóide de 4 períodos com amplitude de 127 desenhada na horizontal, como aquela apresentada na Figura 6 . Grave a imagem no formato PNG e no formato YML. Compare os arquivos gerados, extraindo uma linha de cada imagem gravada e comparando a diferença entre elas. Trace um gráfico da diferença calculada ao longo da linha correspondente extraída nas imagens. O que você observa?

4. Decomposição de imagens em planos de bits

Com apenas 8 bits é possível representar cada componente de cor em uma imagem em uma faixa de variação de 0 a 255. Apesar ter apenas um byte de tamanho, essa quantidade permite enganar com maestria o olho humano e ainda possibilita uma gama de aproximadamente 16 milhões de tonalidades de cores para compor uma imagem.

Os bits mais significativos dos pixels de uma imagem guardam as informações mais importantes para a composição da cor, ao passo que menos significativos pouca informação detém para olhos medianos. Observe, por exemplo, a sequência de imagens na Figura 8. Ela apresenta os planos de bits da imagem biel.png, onde cada uma mostra valores iguais a 0 ou 255, ou seja, são imagens monocromáticas com um bit por pixel. A imagem do canto superior esquerdo mostra o plano de bits menos significativo, enquanto a imagem do canto inferior direito mostra o plano de bits mais significativo. Perceba que nos planos de bits menos significativos pouca informação sobre a imagem é revelada, enquanto nos planos de bits mais significativos a imagem é revelada com mais detalhes.

bitplanes
Figura 8. Planos de bits em uma imagem

A imagem seguinte foi composta manipulando os bits de cada pixel de forma que os N bits menos significativos de cada componente de cor fossem deixados com valores iguais a zero para seis valores distintos de N, variando de 0 (canto superior esquerdo) a 7 (canto inferior direito).

bitszero
Figura 9. Anulando planos de bits

Perceba como ocorre a degradação das cores da imagem na medida em que os bits são descartados. Para a imagem do exemplo, a degradação começa a ser percebida com a perda de 3 ou 4 bits menos significativos. É justamente aí que entra a possibilidade de usar os bits menos significativos da figura para ocultar informação, posto que sua influência geralmente não é perceptível na imagem.

4.1. Esteganografia em imagens digitais

Esteganografia é uma área da criptologia que se ocupa de ocultar uma informação em outra, de sorte a tornar despercebida uma determinada mensagem. Ela pode ser feita no computador usando arquivos de texto, imagens ou vídeos, de modo que apenas o receptor que conhece como a ocultação foi realizada saiba como recuperar a informação inserida. Na esteganografia, usa-se o princípio da ocultação por obscuridade, onde pressupõe-se que apenas o remetente e o destinatário sabem como decifrar o segredo enviado.

Embora esteja um desuso pelos algoritmos modernos de criptografia, ainda é possível se divertir um pouco com isso, combinando esteganografia com imagens digitais. A ideia é esconder uma imagem secreta einformáticam outra (imagem portadora), mas sem alterar significativamente a aparência da portadora.

Descartando-se uma quantidade de bits menos significativos de cada pixel, suficiente para não perder a qualidade visual da imagem, pode-se usar posições dos bits perdidos para esconder informação. Dá-se a essa prática o nome de esteganografia de bits menos significativos, ou Least Significant Bit steganography.

A ideia é esconder a imagem biel.jpg na imagem sushi.jpg, de modo que a imagem resultante não apresente diferenças significativas em relação à imagem portadora.

sushi biel
Figura 10. Imagem portadora e imagem escondida

Para isso, serão preservados os 5 bits mais significativos (MSB) dos pixels da imagem portadora e os 3 bits mais significativos da imagem escondida serão colocados no lugar dos 3 bits menos significativos (LSB) da imagem portadora, como ilustra a Figura 11. É importante observar que ambas as imagem escondida deve ter no máximo o tamanho da imagem portadora.

bit composition
Figura 11. Composição de bits da Imagem portadora e imagem escondida

Na Listagem 6 que segue é mostrado como esconder o conteúdo de uma imagem em outra utilizando operadores de manipulação de bits com OpenCV.

Listagem 6. esteg-encode.cpp
#include <iostream>
#include <opencv2/opencv.hpp>

int main(int argc, char**argv) {
  cv::Mat imagemPortadora, imagemEscondida, imagemFinal;
  cv::Vec3b valPortadora, valEscondida, valFinal;
  int nbits = 3;

  imagemPortadora = cv::imread(argv[1], cv::IMREAD_COLOR);
  imagemEscondida = cv::imread(argv[2], cv::IMREAD_COLOR);

  if (imagemPortadora.empty() || imagemEscondida.empty()) {
    std::cout << "imagem nao carregou corretamente" << std::endl;
    return (-1);
  }
  if (imagemPortadora.rows != imagemEscondida.rows ||
      imagemPortadora.cols != imagemEscondida.cols) {
    std::cout << "imagens devem ter o mesmo tamanho" << std::endl;
    return (-1);
  }

  imagemFinal = imagemPortadora.clone();

  for (int i = 0; i < imagemPortadora.rows; i++) {
    for (int j = 0; j < imagemPortadora.cols; j++) {
      valPortadora = imagemPortadora.at<cv::Vec3b>(i, j);
      valEscondida = imagemEscondida.at<cv::Vec3b>(i, j);
      valPortadora[0] = valPortadora[0] >> nbits << nbits;
      valPortadora[1] = valPortadora[1] >> nbits << nbits;
      valPortadora[2] = valPortadora[2] >> nbits << nbits;
      valEscondida[0] = valEscondida[0] >> (8-nbits);
      valEscondida[1] = valEscondida[1] >> (8-nbits);
      valEscondida[2] = valEscondida[2] >> (8-nbits);
      valFinal[0] = valPortadora[0] | valEscondida[0];
      valFinal[1] = valPortadora[1] | valEscondida[1];
      valFinal[2] = valPortadora[2] | valEscondida[2];
      imagemFinal.at<cv::Vec3b>(i, j) = valFinal;
    }
  }
  imwrite("esteganografia.png", imagemFinal);
  return 0;
}

Para compilar e executar o programa esteg-encode.cpp, salve-o juntamente com os arquivo Makefile e a imagens sushi.jpg biel.jpg em um diretório e execute a seguinte seqüência de comandos:

$ make esteg-encode
$ ./esteg-encode sushi.jpg biel.jpg

4.2. Descrição do programa

valPortadora[0] = valPortadora[0] >> nbits << nbits;
valEscondida[0] = valEscondida[0] >> (8-nbits);
valFinal[0] = valPortadora[0] | valEscondida[0];

A primeira linha desse trecho faz com que os N bits menos significativos da imagem portadora sejam anulados, onde N é o número de bits que serão usados para esconder a imagem codificada. A segunda linha faz com que os N bits mais significativos da imagem codificada sejam deslocados à direita para a posição dos N bits menos significativos na variável. A terceira linha faz a combinação dos valores das duas imagens, de modo que os N bits menos significativos da imagem portadora sejam substituídos pelos N bits mais significativos da imagem codificada.

A imagem resultante da esteganografia será gravada no arquivo esteganografia.png. O resultado da esteganografia é mostrado na Figura 12. Observe que a imagem resultante não apresenta diferenças visuais significativas em relação à imagem portadora.

esteganografia result
Figura 12. Imagem resultante da esteganografia

A decomposição em planos de bits da imagem resultante da esteganografia mostra que os bits menos significativos da imagem portadora foram substituídos pelos bits mais significativos da imagem codificada, conforme mostra a Figura 13. A imagem do canto superior esquerdo mostra o plano de bits menos significativo, enquanto a imagem do canto inferior direito mostra o plano de bits mais significativo.

bitplanes sushi biel
Figura 13. Planos de bits em uma imagem

4.3. Exercícios

  • Usando o programa esteg-encode.cpp como referência para esteganografia, escreva um programa que recupere a imagem codificada de uma imagem resultante de esteganografia. Lembre-se que os bits menos significativos dos pixels da imagem fornecida deverão compor os bits mais significativos dos pixels da imagem recuperada. O programa deve receber como parâmetros de linha de comando o nome da imagem resultante da esteganografia. Teste a sua implementação com a imagem da Figura 14 (desafio-esteganografia.png).

desafio esteganografia
Figura 14. Imagem codificada

5. Preenchendo regiões

Uma tarefa bastante comum em processamento de imagens e visão artificial é contar a quantidade de objetos presentes em uma cena.

Para contar os objetos é necessário identificar os aglomerados de pixels associados a cada um. Neste exemplo, assume-se que a imagem é do tipo binária, ou seja, cada pixel assume apenas dois valores - 0 ou 255 - indicando que o pixel pertence ao fundo da imagem ("0") ou a algum objeto presente ("255"). Assume-se também que cada aglomerado de pixels será interpretado como um objeto individual. Esse é o processo mais comum para operações de contagem de objetos em uma imagem.

Uma das maneiras de identificar as regiões de forma única é através de rotulação. A rotulação de regiões é o processo pelo qual regiões com características comuns recebem um identificador comum (rótulo).

Em geral, um algoritmo de rotulação de imagens binárias recebe como entrada uma imagem binária e fornece como saída uma imagem em tons de cinza, com as várias regiões representativas de objetos rotuladas com um tom de cinza diferente.

No exemplo dessa lição será mostrado como rotular uma imagem binária, utilizando o algoritmo floodfill (ou seedfill) para descobrir os aglomerados de pixels. A imagem usada para teste será a presente no arquivo bolhas.png mostrada na Figura Bolhas.

bolhas
Figura 15. Imagem bolhas.png

O programa de referência utilizado para essa tarefa, labeling.cpp, é mostrado na Listagem Labeling.

Listagem 7. labeling.cpp
#include <iostream>
#include <opencv2/opencv.hpp>

using namespace cv;

int main(int argc, char** argv) {
  cv::Mat image, realce;
  int width, height;
  int nobjects;

  cv::Point p;
  image = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);

  if (!image.data) {
    std::cout << "imagem nao carregou corretamente\n";
    return (-1);
  }

  width = image.cols;
  height = image.rows;
  std::cout << width << "x" << height << std::endl;

  p.x = 0;
  p.y = 0;

  // busca objetos presentes
  nobjects = 0;
  for (int i = 0; i < height; i++) {
    for (int j = 0; j < width; j++) {
      if (image.at<uchar>(i, j) == 255) {
        // achou um objeto
        nobjects++;
        // para o floodfill as coordenadas
        // x e y são trocadas.
        p.x = j;
        p.y = i;
        // preenche o objeto com o contador
        cv::floodFill(image, p, nobjects);
      }
    }
  }
  std::cout << "a figura tem " << nobjects << " bolhas\n";
  cv::imshow("image", image);
  cv::imwrite("labeling.png", image);
  cv::waitKey();
  return 0;
}

Para compilar e executar o programa labeling.cpp, salve-o juntamente com os arquivo Makefile e bolhas.png em um diretório e execute a seguinte seqüência de comandos:

$ make labeling
$ ./labeling bolhas.png

A saída do programa labeling é mostrado na Figura Labeling

labeling
Figura 16. Saída do programa labeling

5.1. Descrição do programa labeling.cpp

cv::Point p;

A estrutura Point define um ponto na segunda dimensão que permite acesso às suas coordenadas x e y. Ele será usado no exemplo para indicar a semente de preenchimento que é usada pelo algoritmo floodfill.

image = cv::imread(argv[1],cv::IMREAD_GRAYSCALE);

Independentemente do formato da imagem de entrada, ela será convertida para tons de cinza, uma vez que o exemplo assume essa condição.

p.x=0;
p.y=0;

Nesta fase tem início o processo de rotulação das várias regiões da imagem. Assumindo que os pixels do objeto possuem tom de cinza igual a 255, o algoritmo percorre toda a imagem, linha após linha, de cima a baixo, da esquerda para direita por pixels que tenham tom igual a 255.

Quando um elemento da matriz é encontrado com tom de cinza igual a 255, o algoritmo floodfill é executado utilizando as coordenadas desse ponto como semente.

A operação do algoritmo floodfill é bem simples: dado um ponto semente, o algoritmo sai procurando os 4- ou 8-vizinhos desse ponto (conforme configuração estabelecida) que possuem a mesma propriedade do ponto semente (geralmente o tom de cinza). Para cada ponto encontrado, muda-se sua propriedade para uma nova propriedade fornecida. Para cada ponto encontrado, também, realiza-se a busca de vizinhança para os seus 4- ou 8-vizinhos que contenham a mesma propriedade da semente. Esse processo é repetido até que não restem mais pontos com propriedade alterada na componente conectada (ou região conectada).

nobjects=0;

Inicia a contagem de objetos (inicialmente, zero objetos estão presentes)

for(int i=0; i<height; i++){
  for(int j=0; j<width; j++){
    if(image.at<uchar>(i,j) == 255){
      nobjects++;
      p.x=j;
      p.y=i;
      cv::floodFill(image,p,nobjects);
    }
  }
}

A contagem funciona percorrendo as linhas e colunas da matriz image em busca de elementos com tom de cinza igual a 255 (pixel de objeto). Quando encontrado, incrementa-se o contador de objeto e executa-se o algoritmo floodfill na imagem utilizando o pixel encontrado como semente. Observe que a região à qual o pixel pertence será rotulada com tom de cinza igual ao número de contagem de objetos atual.

O processo continua até que toda a imagem tenha sido rotulada.

cv::imshow("image", image);
cv::imwrite("labeling.png", image);
cv::waitKey();

Finalmente, a imagem image é mostrada (já completamente rotulada) e então gravada no arquivo labeling.png. Uma das linhas com o comando imshow é usada apenas para mostrar a imagem com um pouco de realce (para fins de melhor visualização). Como esse efeito funciona será discutido mais adiante.

5.2. Exercícios

  • Observando-se o programa labeling.cpp como exemplo, é possível verificar que caso existam mais de 255 objetos na cena, o processo de rotulação poderá ficar comprometido. Identifique a situação em que isso ocorre e proponha uma solução para este problema.

  • Aprimore o algoritmo de contagem apresentado para identificar regiões com ou sem buracos internos que existam na cena. Assuma que objetos com mais de um buraco podem existir. Inclua suporte no seu algoritmo para não contar bolhas que tocam as bordas da imagem. Não se pode presumir, a priori, que elas tenham buracos ou não.

6. Manipulação de histogramas

O objetivo dessa lição é mostrar como tratar histogramas de imagens usando OpenCV. Histogramas são ferramentas interessantes para avaliar características de uma imagem ou de atributos que dela são extraídos.

Um histograma é uma contagem de dados onde se organiza as ocorrências por faixas de valores predefinidos. Em se tratando de imagens digitais em tons de cinza, por exemplo, costuma-se associar um histograma com a contagem de ocorrências de cada um dos possíveis tons em uma imagem. A grosso modo, o histograma oferece uma estimativa da probabilidade de ocorrência dos tons de cinza na imagem.

Exemplos típicos do uso de histogramas podem ser encontrados na segmentação automática de imagens, detecção de movimento e granulometria.

Além disso, a lição deverá explorar o uso dos recursos de captura de vídeo disponíveis no OpenCV para lidar com câmeras conectadas ao sistema.

O exemplo da Listagem Histograma mostra o processo de capturar imagens de uma webcam instalada no computador, calcular os histogramas das componentes de cor das imagens e desenhá-los no canto superior esquerdo da imagem capturada.

Listagem 8. histogram.cpp
#include <iostream>
#include <opencv2/opencv.hpp>

int main(int argc, char** argv){
  cv::Mat image;
  int width, height;
  cv::VideoCapture cap;
  std::vector<cv::Mat> planes;
  cv::Mat histR, histG, histB;
  int nbins = 64;
  float range[] = {0, 255};
  const float *histrange = { range };
  bool uniform = true;
  bool acummulate = false;
  int key;

  cap.open(0);
  
  if(!cap.isOpened()){
    std::cout << "cameras indisponiveis";
    return -1;
  }
  
  cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
  cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);  
  width = cap.get(cv::CAP_PROP_FRAME_WIDTH);
  height = cap.get(cv::CAP_PROP_FRAME_HEIGHT);

  std::cout << "largura = " << width << std::endl;
  std::cout << "altura  = " << height << std::endl;

  int histw = nbins, histh = nbins/2;
  cv::Mat histImgR(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
  cv::Mat histImgG(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
  cv::Mat histImgB(histh, histw, CV_8UC3, cv::Scalar(0,0,0));

  while(1){
    cap >> image;
    cv::split (image, planes);
    cv::calcHist(&planes[0], 1, 0, cv::Mat(), histB, 1,
                 &nbins, &histrange,
                 uniform, acummulate);
    cv::calcHist(&planes[1], 1, 0, cv::Mat(), histG, 1,
                 &nbins, &histrange,
                 uniform, acummulate);
    cv::calcHist(&planes[2], 1, 0, cv::Mat(), histR, 1,
                 &nbins, &histrange,
                 uniform, acummulate);
    
    cv::normalize(histR, histR, 0, histImgR.rows, cv::NORM_MINMAX, -1, cv::Mat());
    cv::normalize(histG, histG, 0, histImgG.rows, cv::NORM_MINMAX, -1, cv::Mat());
    cv::normalize(histB, histB, 0, histImgB.rows, cv::NORM_MINMAX, -1, cv::Mat());
    
    histImgR.setTo(cv::Scalar(0));
    histImgG.setTo(cv::Scalar(0));
    histImgB.setTo(cv::Scalar(0));
    
    for(int i=0; i<nbins; i++){
      cv::line(histImgR,
               cv::Point(i, histh),
               cv::Point(i, histh-cvRound(histR.at<float>(i))),
               cv::Scalar(0, 0, 255), 1, 8, 0);
      cv::line(histImgG,
               cv::Point(i, histh),
               cv::Point(i, histh-cvRound(histG.at<float>(i))),
               cv::Scalar(0, 255, 0), 1, 8, 0);
      cv::line(histImgB,
               cv::Point(i, histh),
               cv::Point(i, histh-cvRound(histB.at<float>(i))),
               cv::Scalar(255, 0, 0), 1, 8, 0);
    }
    histImgR.copyTo(image(cv::Rect(0, 0       ,nbins, histh)));
    histImgG.copyTo(image(cv::Rect(0, histh   ,nbins, histh)));
    histImgB.copyTo(image(cv::Rect(0, 2*histh ,nbins, histh)));
    cv::imshow("image", image);
    key = cv::waitKey(30);
    if(key == 27) break;
  }
  return 0;
}

Para compilar e executar o programa histogram.cpp, salve-o juntamente com o arquivo Makefile em um diretório e execute a seguinte seqüência de comandos:

$ make histogram
$ ./histogram

A saída do programa histogram é mostrado na Figura 17

histogram
Figura 17. Saída do programa histogram

6.1. Descrição do programa histogram.cpp

cv::VideoCapture cap;

Fontes de captura de vídeo são acessadas no OpenCV através da classe VideoCapture. Com ela, o usuário pode abrir um fluxo de vídeo oriundo de um arquivo de vídeo, sequência de imagens ou de um dispositivo de captura. Neste último caso, os dispositivos são identificados por um índice que inicia em 0.

As imagens capturadas nesse exemplo serão extraídas de um fluxo de vídeo que será conectado ao objeto cap.

std::vector<cv::Mat> planes;
cv::Mat histR, histG, histB;
int nbins = 64;

O cálculo do histograma será realizado para cada uma das componentes de cor de forma independente. Logo, a separação das componentes em matrizes independentes será feita no vetor de matrizes planes. Assim, planes[0], planes[1] e planes[2] armazenarão as componentes de cor Vermelho, Verde e Azul, respectivamente.

As três matrizes histR, histG e histB guardarão os histogramas de suas respectivas componentes de cor.

A variável nbins define o tamanho do vetor utilizado para armazenar os histogramas. O tamanho do histograma não precisa ser necessariamente o mesmo do ton de cinza máximo previsto para uma componente de cor (ex: 256 para imagens RGB). É possível especificar a quantidade de faixas (ou bins) que serão usadas para quantificar as ocorrências dos tons.

No exemplo, usa-se um total de 64 faixas para um tom de cinza máximo igual a 255. No cálculo, portanto, as ocorrências de tom de cinza na faixa \$[0,3\$] serão contabilizadas no primeiro elemento do array com o histograma; as ocorrências na faixa \$[4,7\$] contarão no segundo elemento do histograma, e assim por diante.

float range[] = {0, 256};
const float *histrange = { range };

É preparada na variável histrange a faixa de valores (mínimo e máximo) presentes na imagem cujo histograma será calculado. Essa variável, da forma como é definida, é usada pela função de cálculo de histograma.

bool uniform = true;
bool acummulate = false;
cap.open(0);

if(!cap.isOpened()){
  cout << "cameras indisponiveis";
  return -1;
}

cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);
width = cap.get(cv::CAP_PROP_FRAME_WIDTH);
height = cap.get(cv::CAP_PROP_FRAME_HEIGHT);

Abre-se a conexão com o primeiro dispositivo de captura de vídeo disponível. Os dispositivos são identificados em sequência. Logo, se um sistema dispõe de duas câmeras, por exemplo, a primeira será associada ao identificador "0" e a segunda ao identificador "1".

Uma vez chamado o método open(), verifica-se se o dispositivo de captura está devidamente conectado para proceder com o restante das tarefas.

Neste exemplo, usamos o método set() para atribuir um tamanho aos quadros capturados pela câmera. A escolha foi feita de modo a fixar um tamanho altura x largura igual a 640x480 pixels. Contudo, é importante observar que as resoluções suportadas são dependentes do dispositivo usado para a captura dos quadros, de sorte que essas duas linhas poderão não surtir efeito em alguns casos.

Finalmente, lê-se a largura (width) e altura(height) dos quadros que serão disponíveis pelo dispositivo. A classe VideoCapture possui diversos métodos para ajustar os parâmetros de captura para o dispositivo conectado. Entretanto, na versão do OpenCV em que foram feitos os testes aqui descritos, alguns podem não funcionar corretamente dependendo do tipo de dispositivo utilizado.

int histw = nbins, histh = nbins/2;
cv::Mat histImgR(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgG(histh, histw, CV_8UC3, cv::Scalar(0,0,0));
cv::Mat histImgB(histh, histw, CV_8UC3, cv::Scalar(0,0,0));

Define-se a largura e altura das imagens que serão usadas para desenhar os histogramas de cada uma das componentes de cor. Note que a altura da imagem é igual à metade da largura para fins de exibição. As imagens são criadas com o tipo CV_8UC3, ou seja, com 8 bits por pixel, com tipo de dados unsigned char contendo 3 canais de cor. A cor, nesse caso, servirá apenas para que o histograma seja desenhado na cor respectiva de sua componente.

cap >> image;
cv::split (image, planes);

Em um loop infinito, as imagens são capturadas, quadro a quadro, do dispositivo de entrada conectado e armazenadas no objeto image. Dispositivos de captura normalmente disponibilizam imagens com suporte a cor, ou seja, cada matriz possui normalmente três planos de cor. Logo, os histogramas deverão ser calculados para cada um desses planos, de modo que a função split() faz a separação adequada para que se proceda com o cálculo.

Histogramas hiperdimensionais que contabilizam as ocorrências das combinações R,G e B dos pixels de uma imagem são possíveis de serem calculados. Entretanto, normalmente são usadas matrizes esparças para isso. Considerando imagens com 8 bits por pixel para cada plano de cor, seria necessário uma matriz com 256 x 256 x 256 elementos para guardar o histograma até mesmo de uma imagem pequena. Esse processo é dispendioso e, normalmente, não possui muita utilidade.

Na análise de histograma, portanto, geralmente se avalia cada componente de cor de forma independente.

cv::calcHist(&planes[0], 1, 0, Mat(), histR, 1,
            &nbins, &histrange,
            uniform, acummulate);
cv::calcHist(&planes[1], 1, 0, Mat(), histG, 1,
             &nbins, &histrange,
             uniform, acummulate);
cv::calcHist(&planes[2], 1, 0, Mat(), histB, 1,
             &nbins, &histrange,
             uniform, acummulate);

Os histogramas são então calculados para cada uma das componentes de cor. A função calcHist() do OpenCV recebe, na sequência, os seguintes argumentos:

  • Uma referência para imagem que se deseja processar;

  • A quantidade de imagens para se calcular o histograma (uma, neste caso);

  • Um ponteiro para o array de canais das imagens. Para apenas um canal, o endereço 0 deve ser repassado;

  • Uma máscara opcional marcando a região onde se deseja calcular o histograma. Considerando a imagem inteira, fornece-se uma matriz vazia;

  • O array que irá armazenar o histograma;

  • A dimensionalidade do histograma (no exemplo, existe apenas uma dimensão);

  • O endereço da variável que armazena a quantidade de divisões; e

  • Variáveis informando se o histograma é uniforme (divisões de tamanho igual) ou acumulado. Caso não seja uniforme, a variável histrange deverá passar uma lista com os limites superiores de cada faixa.

cv::normalize(histR, histR, 0, histImgR.rows, cv::NORM_MINMAX, -1, Mat());
cv::normalize(histG, histB, 0, histImgR.rows, cv::NORM_MINMAX, -1, Mat());
cv::normalize(histB, histB, 0, histImgR.rows, cv::NORM_MINMAX, -1, Mat());

Cada histograma é normalizado em uma faixa de valores que vai de 0 até a quantidade de linhas da imagem onde este será desenhado. A normalização é feita linearmente entre os valores máximo e mínimo encontrados na componente de cor.

histImgR.setTo(Scalar(0));
histImgG.setTo(Scalar(0));
histImgB.setTo(Scalar(0));

for(int i=0; i<nbins; i++){
  cv::line(histImgR, cv::Point(i, histh),
       cv::Point(i, cvRound(histR.at<float>(i))),
       cv::Scalar(0, 0, 255), 1, 8, 0);
  line(histImgG, cv::Point(i, histh),
       cv::Point(i, cvRound(histG.at<float>(i))),
       cv::Scalar(0, 255, 0), 1, 8, 0);
  line(histImgB, cv::Point(i, histh),
       cv::Point(i, cvRound(histB.at<float>(i))),
       cv::Scalar(255, 0, 0), 1, 8, 0);
}

As imagens com os desenhos dos histogramas são então geradas. Inicialmente, todas são preenchidas com 0 (cor preta). Em seguida, os histogramas são desenhados na forma de um gráfico de barras usando a função line().

histImgR.copyTo(image(cv::Rect(0, 0       ,nbins, histh)));
histImgG.copyTo(image(cv::Rect(0, histh   ,nbins, histh)));
histImgB.copyTo(image(cv::Rect(0, 2*histh ,nbins, histh)));

Finalmente, as imagens dos histogramas são copiadas, uma abaixo da outra, para o canto superior esquerdo da imagem capturada na câmera.

6.2. Exercícios

  • Utilizando o programa exemplos/histogram.cpp como referência, implemente um programa equalize.cpp. Este deverá, para cada imagem capturada, realizar a equalização do histogram antes de exibir a imagem. Teste sua implementação apontando a câmera para ambientes com iluminações variadas e observando o efeito gerado. Assuma que as imagens processadas serão em tons de cinza.

  • Utilizando o programa exemplos/histogram.cpp como referência, implemente um programa motiondetector.cpp. Este deverá continuamente calcular o histograma da imagem (apenas uma componente de cor é suficiente) e compará-lo com o último histograma calculado. Quando a diferença entre estes ultrapassar um limiar pré-estabelecido, ative um alarme. Utilize uma função de comparação que julgar conveniente.

7. Filtragem no domínio espacial I

A convolução é um processo pelo qual duas funções se combinam para formar uma terceira função no domínio espacial. Tal processo resulta do deslocamento de uma função sobre a outra e do cálculo de uma combinação linear entre ambas em cada ponto do deslocamento.

Em se tratando de uma imagem digital, a convolução é chamada de convolução digital. Sua principal aplicação é na filtragem de sinais, permitindo que características de uma dada imagem sejam alteradas conforme o tipo de efeito que se deseja impor.

A convolução discreta entre duas imagens pode ser definida como

\$h(x,y) = f(x,y)*g(x,y) = \frac{1}{MN} \sum_{m=0}^{M-1}\sum_{n=0}^{N-1}f(m,n) g(x-m, y-n)\$

As funções \$f(x,y)\$ e \$g(x,y)\$ normalmente estão associadas à imagem a ser filtrada e ao filtro digital associado.

Existem dois tipos de convolução: a 'convolução linear' e a 'convolução circular'. Na primeira, assume-se que os sinais \$f(x,y)\$ e \$g(x,y)\$ existem em duas regiões com M e N amostras consecutivas, respectivamente, sendo zero fora desssas regiões. A região resultante da convolução terá suporte de tamanho \$M+N-1\$. Fora desta, o resultado da convolução será nulo. Na segunda, assume-se que as sequências \$f(x,y)\$ e \$g(x,y)\$ são periódicas e com um mesmo período \$M=N\$. O resultado da convolução, \$h(x,y)\$ possuirá também o mesmo período \$M\$.

Costuma-se simplificar essa equação e calcular os tons de cinza da imagem filtrada realizando o produto entre os coeficientes de uma pequena matriz comumente denominada 'máscara' e as intensidades dos pixels sobre uma posição específica na imagem.

As máscaras normalmente possuem dimensões de tamanho ímpar (\$3 \times 3\$ elementos , \$5 \times 5\$ elementos, \$7 \times 7\$ elementos, etc), dependendo da intensidade da filtragem que se deseja realizar.

Considere uma imagem digital denotada por \$f(x,y)\$, uma matriz de máscara denotada por \$w(s,t)\$ e uma image filtrada denotada por \$g(x,y)\$. Para uma máscara de tamanho \$3 \times 3\$ elementos, o processo de filtragem no domínio espacial é ilustrado na Figura 18.

filtragemespacial
Figura 18. Filtragem espacial

No processo, a imagem da máscara é deslocada (pixel a pixel) sobre a imagem a ser filtrada. Para cada deslocamento, calcula-se o somatório do produto entre os valores dos elementos da máscara e os tons de cinza dos pixels que esta sobrepõe e atribui-se o resultado ao pixel respectivo na imagem filtrada.

Muitos efeitos de filtragem são possíveis de se obter modificando os valores da imagem da máscara: borramento, aguçamento e detecção de bordas são os principais deles.

O programa de referência utilizado para essa tarefa, filtroespacial.cpp, é mostrado na Listagem Filtroespacial.

Listagem 9. filtroespacial.cpp
#include <iostream>
#include <opencv2/opencv.hpp>

void printmask(cv::Mat &m) {
  for (int i = 0; i < m.size().height; i++) {
    for (int j = 0; j < m.size().width; j++) {
      std::cout << m.at<float>(i, j) << ",";
    }
    std::cout << "\n";
  }
}

int main(int, char **) {
  cv::VideoCapture cap;  // open the default camera
  float media[] = {0.1111, 0.1111, 0.1111, 0.1111, 0.1111,
                   0.1111, 0.1111, 0.1111, 0.1111};
  float gauss[] = {0.0625, 0.125,  0.0625, 0.125, 0.25,
                   0.125,  0.0625, 0.125,  0.0625};
  float horizontal[] = {-1, 0, 1, -2, 0, 2, -1, 0, 1};
  float vertical[] = {-1, -2, -1, 0, 0, 0, 1, 2, 1};
  float laplacian[] = {0, -1, 0, -1, 4, -1, 0, -1, 0};
  float boost[] = {0, -1, 0, -1, 5.2, -1, 0, -1, 0};

  cv::Mat frame, framegray, frame32f, frameFiltered;
  cv::Mat mask(3, 3, CV_32F);
  cv::Mat result;
  double width, height;
  int absolut;
  char key;

  cap.open(0);

  if (!cap.isOpened())  // check if we succeeded
    return -1;

  cap.set(cv::CAP_PROP_FRAME_WIDTH, 640);
  cap.set(cv::CAP_PROP_FRAME_HEIGHT, 480);
  width = cap.get(cv::CAP_PROP_FRAME_WIDTH);
  height = cap.get(cv::CAP_PROP_FRAME_HEIGHT);
  std::cout << "largura=" << width << "\n";
  ;
  std::cout << "altura =" << height << "\n";
  ;
  std::cout << "fps    =" << cap.get(cv::CAP_PROP_FPS) << "\n";
  std::cout << "format =" << cap.get(cv::CAP_PROP_FORMAT) << "\n";

  cv::namedWindow("filtroespacial", cv::WINDOW_NORMAL);
  cv::namedWindow("original", cv::WINDOW_NORMAL);

  mask = cv::Mat(3, 3, CV_32F, media);

  absolut = 1;  // calcs abs of the image

  for (;;) {
    cap >> frame;  // get a new frame from camera
    cv::cvtColor(frame, framegray, cv::COLOR_BGR2GRAY);
    cv::flip(framegray, framegray, 1);
    cv::imshow("original", framegray);
    framegray.convertTo(frame32f, CV_32F);
    cv::filter2D(frame32f, frameFiltered, frame32f.depth(), 
    mask,
                 cv::Point(1, 1), 0);
    if (absolut) {
      frameFiltered = cv::abs(frameFiltered);
    }

    frameFiltered.convertTo(result, CV_8U);

    cv::imshow("filtroespacial", result);

    key = (char)cv::waitKey(10);
    if (key == 27) break;  // esc pressed!
    switch (key) {
      case 'a':
        absolut = !absolut;
        break;
      case 'm':
        mask = cv::Mat(3, 3, CV_32F, media);
        printmask(mask);
        break;
      case 'g':
        mask = cv::Mat(3, 3, CV_32F, gauss);
        printmask(mask);
        break;
      case 'h':
        mask = cv::Mat(3, 3, CV_32F, horizontal);
        printmask(mask);
        break;
      case 'v':
        mask = cv::Mat(3, 3, CV_32F, vertical);
        printmask(mask);
        break;
      case 'l':
        mask = cv::Mat(3, 3, CV_32F, laplacian);
        printmask(mask);
        break;
      case 'b':
        mask = cv::Mat(3, 3, CV_32F, boost);
        break;
      default:
        break;
    }
  }
  return 0;
}

Para compilar e executar o programa filtroespacial.cpp, salve-o juntamente com o arquivo Makefile em um diretório e execute a seguinte seqüência de comandos:

$ make filtroespacial
$ ./filtroespacial

A saída do programa filtroespacial apresentará duas janelas: uma com a imagem original capturada e outra com o resultado da filtragem. O filtro inicial escolhido no exemplo é o da média.

7.1. Descrição do programa filtroespacial.cpp

  float media[] = {0.1111,0.1111,0.1111,
                   0.1111,0.1111,0.1111,
                   0.1111,0.1111,0.1111};
  float gauss[] = {0.0625,0.125,0.0625,
                   0.125,0.25,0.125,
                   0.0625,0.125,0.0625};
  float horizontal[]={-1,0,1,
                      -2,0,2,
                      -1,0,1};
  float vertical[]={-1,-2,-1,
                    0,0,0,
                    1,2,1};
  float laplacian[]={0,-1,0,
                     -1,4,-1,
                     0,-1,0};
  float boost[]={0,-1,0,
                 -1,5.2,-1,
                 0,-1,0};

Os filtros usados no exemplo (determinados pelas matrizes de máscara) são de tamanho \$3 \times 3\$ pixels. Cinco tipos de filtros são testados: média, gaussiano, detector de bordas horizontais, detector de bordas verticais e laplaciano. Os coeficientes de cada filtro são armazenados em arrays unidimensionais que serão repassados ao construtor da matriz do filtro.

mask = cv::Mat(3, 3, CV_32F, media);

Esse trecho de código mostra o procedimento padrão para construção da matriz que será usada como máscara de filtragem. A variável mask recebe uma matriz de tamanho \$3 \times 3\$ em ponto flutuante (CV_32F) com valores iniciais iguais ao do array media que é repassado. Repare que o tipo da matriz precisa ser estabelecido em ponto flutuante, posto que as operações de cálculo contarão com a presença de números fracionários.

cap >> frame;
cv::cvtColor(frame, framegray, cv::COLOR_BGR2GRAY);
cv::flip(framegray, framegray, 1);

Em loop infinito, imagens coloridas são capturadas constantemente na matriz cap e convertidas em equivalentes em tons de cinza usando a função cvtColor(). A imagem então é invertida horizontalmente com a função flip(). A inversão é feita apenas para fins de tornar a interação com o programa exemplo semelhante à de um espelho.

framegray.convertTo(frame32f, CV_32F);
filter2D(frame32f, frameFiltered, frame32f.depth(), mask, cv::Point(1,1), 0);

Este trecho é responsável pelo cálculo da filtragem espacial. Cada imagem em tom de cinza armazenada na variável framegray é convertida para outra equivalente com representação em ponto flutuante - frame32f. A conversão é necessária devido aos tipos de operação que serão realizados pela função filter2D(). Observe apenas que OpenCV replica os pixels na borda ('ao invés de preencher de zeros') durante o processo de filtragem.

A função filter2d() recebe então a matriz da imagem em ponto flutuante - frame32f - e produz a matriz frameFiltered, de acordo com o tipo do elemento da matriz de entrada - neste caso, CV_32F (ou float). O objeto Point(1,1) que é repassado como próximo argumento identifica a origem do sistema de coordenadas atribuído para a máscara que, neste caso, é o ponto central da matriz.

if(absolut){
  frameFiltered=cv::abs(frameFiltered);
}
frameFiltered.convertTo(result, CV_8U);

Caso a opção de módulo esteja selecionada, o cálculo é então procedido. A imagem filtrada é então convertida para tons de cinza para posterior exibição na tela.

O restante do código trata apenas da adaptação da matriz mask conforme o filtro escolhido pelo usuário para ser aplicado à imagem capturada.

7.2. Exercícios

  • Utilizando o programa exemplos/filtroespacial.cpp como referência, implemente um programa laplgauss.cpp. O programa deverá acrescentar mais uma funcionalidade ao exemplo fornecido, permitindo que seja calculado o laplaciano do gaussiano das imagens capturadas. Compare o resultado desse filtro com a simples aplicação do filtro laplaciano.

8. Filtragem no domínio espacial II

Este capítulo visa explorar um pouco mais do uso de filtragem espacial aplicando seus princípios para simular uma técnica de fotografia denominada tilt-shift.

A técnica fotográfica de tilt-shift envolve o uso de deslocamentos e rotações entre a lente e o plano de projeção (onde fica filme fotográfico ou o sensor da câmera) de modo a desfocar seletivamente regiões do assunto.

O princípio básico dessa técnica é ilustrado na Figura 19.

tiltshift
Figura 19. Princípio de funcionamento do tilt shift

Na lente normal, o plano de projeção é paralelo ao plano de foco com o assunto que se deseja registrar. Quando a lente é submetida a uma inclinação (tilt), o plano de foco forma um ângulo diferente de zero com o plano de projeção, mudando assim a região que ficará em foco na imagem registrada pela câmera. Se a lente for deslocada para cima ou para baixo (shift), é possível também escolher seletivamente a região que ficará em foco, complementando o uso da técnica.

A técnica de tilt-shift consegue criar belos efeitos fotográficos, simulando miniaturas. O foco seletivo que a lente produz engana o olho humano, dando a impressão que a imagem foi registrada de uma cena em miniatura. Tomando a imagem usando ângulos e proporções adequadas do assunto, dá para se produzir versões em minatura de cenas reais que podem ser bastante convincentes.

Lentes que produzem esse efeito não são baratas quando comparadas a lentes normais. Entrentanto, o efeito produzido por estas lentes pode ser reproduzido usando técnicas simples de processamento digital de imagens.

O princípio utilizado para simular a lente tilt-shift é combinar a imagem original com sua versão filtrada com filtro passa-baixas, de sorte a produzir nas proximidades da borda o efeito do borramento enquanto se mantém na região central a imagem sem borramento.

Uma forma de combinar pode ser realizada com a função addWeighted() do OpenCV. Ela opera calculando a combinação linear de duas imagens \$f_0(x,y)\$ e \$f_1(x,y)\$ pela equação \$g(x,y) = (1 - \alpha)f_0(x,y) + \alpha f_1(x,y)\$, para um dado valor de \$\alpha\$ fornecido.

O programa de referência utilizado para exemplificar o uso da função sugerida, addweighted.cpp, é mostrado na Listagem Addweighted.

Listagem 10. addweighted.cpp
#include <iostream>
#include <cstdio>
#include <opencv2/opencv.hpp>

double alfa;
int alfa_slider = 0;
int alfa_slider_max = 100;

int top_slider = 0;
int top_slider_max = 100;

cv::Mat image1, image2, blended;
cv::Mat imageTop; 

char TrackbarName[50];

void on_trackbar_blend(int, void*){
 alfa = (double) alfa_slider/alfa_slider_max ;
 cv::addWeighted(image1, 1-alfa, imageTop, alfa, 0.0, blended);
 cv::imshow("addweighted", blended);
}

void on_trackbar_line(int, void*){
  image1.copyTo(imageTop);
  int limit = top_slider*255/100;
  if(limit > 0){
    cv::Mat tmp = image2(cv::Rect(0, 0, 256, limit));
    tmp.copyTo(imageTop(cv::Rect(0, 0, 256, limit)));
  }
  on_trackbar_blend(alfa_slider,0);
}

int main(int argvc, char** argv){
  image1 = cv::imread("blend1.jpg");
  image2 = cv::imread("blend2.jpg");
  image2.copyTo(imageTop);
  cv::namedWindow("addweighted", 1);
  
  std::sprintf( TrackbarName, "Alpha x %d", alfa_slider_max );
  cv::createTrackbar( TrackbarName, "addweighted",
                      &alfa_slider,
                      alfa_slider_max,
                      on_trackbar_blend );
  on_trackbar_blend(alfa_slider, 0 );
  
  std::sprintf( TrackbarName, "Scanline x %d", top_slider_max );
  cv::createTrackbar( TrackbarName, "addweighted",
                      &top_slider,
                      top_slider_max,
                      on_trackbar_line );
  on_trackbar_line(top_slider, 0 );

  cv::waitKey(0);
  return 0;
}

Para compilar e executar o programa addweighted.cpp, salve-o juntamente com o arquivo Makefile em um diretório juntamente com as imagens exemplos/blend1.jpg e exemplos/blend2.jpg e execute a seguinte seqüência de comandos:

$ make addweighted
$ ./addweighted

A saída do programa addweighted apresentará uma janela com duas barras de controle: uma que regula o valor de \$alpha\$ e outra que indica a região que será copiada de uma das imagens de entrada na imagem da composição.

Utilizando os recursos do exemplo, é possível conceber uma função de ponderação para combinar a imagem original com sua versão borrada por um filtro da média. Entretanto, o desfoque não deve alterar a região central da imagem final para que o efeito do tiltshift funcione.

Tal processo pode ser modelado usando uma função que define a região de desfoque ao longo do eixo vertical da imagem. Uma possível função que modela esse efeito é dada por

\$\alpha (x) = \frac{1}{2} ( \tanh \frac{x-l1}{d}-tanh\frac{x-l2}{d} )\$

Onde \$l1\$ e \$l2\$ são as linhas cujo valor de \$\alpha\$ assume valor em torno de 0.5, caso os dois valores possuam uma distância adequada um do outro, e \$d\$ indica a força do decaimento da região totalmente oriunda da imagem original para a região totalmente oriunda da imagem borrada.

Para valores \$l1 = -20 \$, \$l2 = 30\$, e \$d = 6\$, por exemplo, a função de ponderação se comportaria como ilustrado na Figura 20.

tiltshift function
Figura 20. Exemplo de função de ponderação para tiltshift

Assumindo que \$\alpha(x)\$ pondere a imagem original (denotada por stem \$f(x,y)\$) e \$1-\alpha(x)\$ pondere a imagem borrada (denotada por \$bf(x,y)\$), a composição \$g(x,y) = \alpha(x) f(x,y) + (1-\alpha(x)) bf(x,y)\$ produzirá o efeito de tiltshift desejado.

O processo de ponderação pode ser realizado por intermédio da função multiply() do OpenCV, destinada à multiplicação de matrizes elemento-a-elemento. Cria-se a imagem que irá ponderar as linhas da imagem original e seu negativo irá ponderar as linhas da imagem borrada. A combinação linear dessas duas imagens fara o efeito simulado de tiltshift. A Figura 21 ilustra possíveis imagens que poderiam ser usadas para ponderação no processo. A da esquerda ponderaria a imagem original e a da direita a imagem borrada.

tiltshift weight
Figura 21. Exemplo de imagens geradas para ponderação no tiltshift

8.1. Exercícios

  • Utilizando o programa exemplos/addweighted.cpp como referência, implemente um programa tiltshift.cpp. Três ajustes deverão ser providos na tela da interface:

    • um ajuste para regular a altura da região central que entrará em foco;

    • um ajuste para regular a força de decaimento da região borrada;

    • um ajuste para regular a posição vertical do centro da região que entrará em foco. Finalizado o programa, a imagem produzida deverá ser salva em arquivo.

  • Utilizando o programa exemplos/addweighted.cpp como referência, implemente um programa tiltshiftvideo.cpp. Tal programa deverá ser capaz de processar um arquivo de vídeo, produzir o efeito de tilt-shift nos quadros presentes e escrever o resultado em outro arquivo de vídeo. A ideia é criar um efeito de miniaturização de cenas. Descarte quadros em uma taxa que julgar conveniente para evidenciar o efeito de stop motion, comum em vídeos desse tipo.

Parte II: Processamento de Imagens no Domínio da Frequência

9. A Tranformada Discreta de Fourier

O objetivo desse capítulo é apresentar a Transformada Discreta de Fourier bidimensional. A Transformada de Fourier é uma transformada capaz de expressar um sinal contínuo como uma combinação de funções de base senoidais ponderadas por coeficientes. Em se tratando de sinais discretos, como é o caso de imagens digitais, a Transformada Discreta de Fourier (ou DFT) é a transformada utilizada.

Para uma imagem digital, a Transformada Discreta de Fourier é capaz de fornecer uma representação alternativa dessa imagem no domínio da frequência, evidenciando degradações que não são facilmente tratadas no domínio espacial. Exemplos de problemas dessa natureza são as interferências periódicas nas transmissões de sinais analógicos, ou repetição de padrões presentes em figuras antigas ou fotos de jornais.

Um exemplo de fotografia corrompida por um padrão senoidal é mostrada na Figura 22. Note que existe uma espécie de grade de pontos presentes nessa imagem.

moire
Figura 22. Exemplo de imagem corrompida por uma cortina de pontos

O espectro de magnitude da Transformada Discreta de Fourier da imagem da Figura 22 é mostrada na Figura 23. Perceba que há um conjunto de manchas simétricas que surgem longe dos eixos, destacando-se do restante do sinal transformado. São essas contribuições as causadoras da grade de pontos e podem ser removidas com o uso de filtros adequados.

dftsenoidal
Figura 23. Transformada Discreta de Fourier da imagem da Figura 22

Para realizar o cálculo da Transformada Discreta de Fourier, é necessário realizar uma série de passos que envolvem a preparação da matriz complexa que deve ser fornecida à função de cálculo da DFT.

Para ilustrar o uso da Transformada Discreta de Fourier, considere o exemplo mostrado na Listagem 11.

Listagem 11. dftimage.cpp
#include <iostream>
#include <vector>
#include <opencv2/opencv.hpp>

void swapQuadrants(cv::Mat& image) {
  cv::Mat tmp, A, B, C, D;

  // se a imagem tiver tamanho impar, recorta a regiao para o maior
  // tamanho par possivel (-2 = 1111...1110)
  image = image(cv::Rect(0, 0, image.cols & -2, image.rows & -2));

  int centerX = image.cols / 2;
  int centerY = image.rows / 2;

  // rearranja os quadrantes da transformada de Fourier de forma que 
  // a origem fique no centro da imagem
  // A B   ->  D C
  // C D       B A
  A = image(cv::Rect(0, 0, centerX, centerY));
  B = image(cv::Rect(centerX, 0, centerX, centerY));
  C = image(cv::Rect(0, centerY, centerX, centerY));
  D = image(cv::Rect(centerX, centerY, centerX, centerY));

  // swap quadrants (Top-Left with Bottom-Right)
  A.copyTo(tmp);
  D.copyTo(A);
  tmp.copyTo(D);

  // swap quadrant (Top-Right with Bottom-Left)
  C.copyTo(tmp);
  B.copyTo(C);
  tmp.copyTo(B);
}

int main(int argc, char** argv) {
  cv::Mat image, padded, complexImage;
  std::vector<cv::Mat> planos; 

  image = imread(argv[1], cv::IMREAD_GRAYSCALE);
  if (image.empty()) {
    std::cout << "Erro abrindo imagem" << argv[1] << std::endl;
    return EXIT_FAILURE;
  }

  // expande a imagem de entrada para o melhor tamanho no qual a DFT pode ser
  // executada, preenchendo com zeros a lateral inferior direita.
  int dft_M = cv::getOptimalDFTSize(image.rows);
  int dft_N = cv::getOptimalDFTSize(image.cols); 
  cv::copyMakeBorder(image, padded, 0, dft_M - image.rows, 0, dft_N - image.cols, cv::BORDER_CONSTANT, cv::Scalar::all(0));

  // prepara a matriz complexa para ser preenchida
  // primeiro a parte real, contendo a imagem de entrada
  planos.push_back(cv::Mat_<float>(padded)); 
  // depois a parte imaginaria com valores nulos
  planos.push_back(cv::Mat::zeros(padded.size(), CV_32F));

  // combina os planos em uma unica estrutura de dados complexa
  cv::merge(planos, complexImage);  

  // calcula a DFT
  cv::dft(complexImage, complexImage); 
  swapQuadrants(complexImage);

  // planos[0] : Re(DFT(image)
  // planos[1] : Im(DFT(image)
  cv::split(complexImage, planos);

  // calcula o espectro de magnitude e de fase (em radianos)
  cv::Mat magn, fase;
  cv::cartToPolar(planos[0], planos[1], magn, fase, false);
  cv::normalize(fase, fase, 0, 1, cv::NORM_MINMAX);

  // caso deseje apenas o espectro de magnitude da DFT, use:
  cv::magnitude(planos[0], planos[1], magn); 

  // some uma constante para evitar log(0)
  // log(1 + sqrt(Re(DFT(image))^2 + Im(DFT(image))^2))
  magn += cv::Scalar::all(1);

  // calcula o logaritmo da magnitude para exibir
  // com compressao de faixa dinamica
  log(magn, magn);
  cv::normalize(magn, magn, 0, 1, cv::NORM_MINMAX);

  // exibe as imagens processadas
  cv::imshow("Imagem", image);  
  cv::imshow("Espectro de magnitude", magn);
  cv::imshow("Espectro de fase", fase);

  cv::waitKey();
  return EXIT_SUCCESS;
}

9.1. Descrição do programa dftimage.cpp

A operação de cálculo da Transformada Discreta de Fourier em OpenCV pode ser realizada pelo seguinte conjunto de passos:

  1. Obtenção da imagem a ser processada.

  2. Padding da imagem com zeros para que seu tamanho seja processável pelo algoritmo de cálculo da FFT (Fast Fourier Transform) implementada no OpenCV.

  3. Criação de uma imagem com dois canais (Real e Imaginário), em ponto flutuante, para ser submetida à função de cálculo da DFT.

  4. Cálculo da DFT da imagem.

  5. Troca de quadrantes para que a origem da imagem transformada fique no centro. Isso ajuda na visualização e projeto de filtros usando o espectro de magnitude da transformada.

  6. Cálculo do espectro de magnitude e de fase da transformada.

  7. Compressão de faixa dinâmica do espectro de magnitude para melhor visualização.

  8. Exibição dos espectros de magnitude e fase da transformada.

image = imread(argv[1], cv::IMREAD_GRAYSCALE);
if (image.empty()) {
  std::cout << "Erro abrindo imagem" << argv[1] << std::endl;
  return EXIT_FAILURE;
}

A imagem a ser processada é lida do disco e armazenada na variável image em formato de escala de cinza. Caso a imagem não seja lida com sucesso, o programa finaliza, apresentando uma mensagem de erro. A conversão para escala de cinza é necessária pois o programa foi desenvolvido para processar imagens em escala de cinza.

dft_M = cv::getOptimalDFTSize(image.rows);
dft_N = cv::getOptimalDFTSize(image.cols);

A função getOptimalDFTSize() identifica os melhores valores com base no tamanho fornecido para acelerar o processo de cálculo da DFT com base em algum algoritmo otimizado. Segundo a documentação do OpenCV, valores múltiplos de dois, três e cinco produzem resultados melhores. Os valores de tamanho ideal para a quantidade de linhas e colunas da imagem são armazenados nas variáveis dft_M e dft_N, respectivamente.

cv::copyMakeBorder(image, padded, 0,
               dft_M - image.rows, 0,
               dft_N - image.cols,
               cv::BORDER_CONSTANT, cv::Scalar::all(0));

A função copyMakeBorder() cria uma versão da imagem fornecida com uma borda preenchida com zeros e ajustada ao tamanho ótimo para cálculo da DFT, conforme indicado pelo uso da função getOptimalDFTSize(). Para uma imagem image fornecida, a saída é produzida na imagem padded. Caso a imagem fornecida já possua dimensões apropriadas, a imagem de saída será igual à de entrada.

planos.push_back(cv::Mat_<float>(padded));
planos.push_back(cv::Mat::zeros(padded.size(), CV_32F));
cv::merge(planos, complexImage);

Esse trecho de código prepara a matriz complexa que será fornecida à função de cálculo do dft. Ambas são enfileiradas em um vetor de matrizes e a função merge() se encarrega de produzir a matriz complexa a partir das matrizes presentes no vetor planos.

// calcula o dft
cv::dft(complexImage, complexImage);

O cálculo do DFT é realizado. Perceba que tanto a matriz de entrada quanto a de saída passadas como parâmetro podem ser a mesma.

// realiza a troca de quadrantes
swapQuadrants(complexImage);

Finalizado o cálculo da DFT, a função swapQuadrants() realiza a troca de quadrantes. Para melhor visualização do espectro de magnitude da transformada, é necessário que o sinal transformado seja deslocado de modo que a origem do sinal fique posicionada no centro da imagem, como ilustra a Figura 24.

dftshift
Figura 24. Deslocamento da imagem transformada

A operação de troca de quadrantes realizada pela função swapQuadrants(). Ela recebe a referência para a matriz que contém a imagem transformada e troca seus quadrantes. Caso a imagem possua tamanho ímpar, ela é diminuída de tamanho em um pixel para que a troca dos quadrantes seja feita usando tamanhos imagens de iguais. Normalmente, trata-se a imagem que será submetida ao cálculo da DFT para que possua dimensões de ordem par, de sorte que essa linha não deverá alterar o tamanho das imagens usualmente fornecidas.

// planos[0] : Re(DFT(image)
// planos[1] : Im(DFT(image)
cv::split(complexImage, planos);

Terminada a troca de quadrantes, a imagem transformada é separada em suas componentes real e imaginária com a função split(). As componentes são então armazenadas na forma de matrizes no vetor planos.

// calcula o espectro de magnitude e de fase (em radianos)
cv::Mat magn, fase;
cv::cartToPolar(planos[0], planos[1], magn, fase, false);
cv::normalize(fase, fase, 0, 1, cv::NORM_MINMAX);

// caso deseje apenas o espectro de magnitude da DFT, use:
cv::magnitude(planos[0], planos[1], magn);

A função cartToPolar() calcula o espectro de magnitude e de fase a partir das componentes real e imaginária da imagem transformada. Esta função foi escolhida especificamente para o exemplo porque já consegue obter os dois espectros ao mesmo tempo. Entretanto, perceba que logo após a normalização do espectro de fase, há uma chamada à função magnitude().

A função magnitude() calcula somente o espectro de magnitude a partir das componentes real e imaginária e, caso o espectro de fase não seja necessário para a análise do sinal transformado, esta é a função mais indicada para o cálculo do espectro de magnitude.

magn += cv::Scalar::all(1);
log(magn, magn);
cv::normalize(magn, magn, 0, 1, cv::NORM_MINMAX);

Esse trecho de código serve para realizar a compressão de faixa dinâmica e normalização do espectro de magnitude na faixa \$0,1\$ para fins de exibição. A compressão de faixa dinâmica é logaritmica e, para evitar erros de cálculo nas situações em que algum dos elementos da matriz magn seja igual a zero, é somado um valor constante a todos os elementos da matriz igual a um.

9.2. Exercícios

  • Utilizando os programa exemplos/dftimage.cpp, calcule e apresente o espectro de magnitude da imagem Figura 7.

  • Compare o espectro de magnitude gerado para a figura Figura 7 com o valor teórico da transformada de Fourier da senóide.

  • Usando agora o filestorage.cpp, mostrado na Listagem 4 como referência, adapte o programa exemplos/dftimage.cpp para ler a imagem em ponto flutuante armazenada no arquivo YAML equivalente (ilustrado na Listagem 5).

  • Compare o novo espectro de magnitude gerado com o valor teórico da transformada de Fourier da senóide. O que mudou para que o espectro de magnitude gerado agora esteja mais próximo do valor teórico? Porque isso aconteceu?

10. Filtragem no Domínio da Frequência

O objetivo da filtragem no domínio da frequência é remover ruídos e distorções geralmente de natureza periódica numa imagem. Neste capítulo, veremos como criar um filtro de frequência e aplicá-lo a uma imagem utilizando a DFT. O filtro de frequência é uma matriz que possui o mesmo tamanho da imagem e que é multiplicada pela transformada de Fourier da imagem para filtrar eventuais problemas que existam na imagem.

O processo de filtragem envolve uma sequência de passos cuja parte delas já foi explorada em outra lição. Os passos para realizar o processo de filtragem são:

  1. Obtenção da imagem a ser processada.

  2. Padding da imagem com zeros para que seu tamanho seja processável pelo algoritmo de cálculo da FFT (Fast Fourier Transform) implementada no OpenCV.

  3. Criação de uma imagem com dois canais (Real e Imaginário), em ponto flutuante, para ser submetida à função de cálculo da DFT.

  4. Cálculo da DFT da imagem.

  5. Troca de quadrantes para que a origem da imagem transformada fique no centro. Isso ajuda na visualização e projeto de filtros usando o espectro de magnitude da transformada.

  6. Criação de um filtro de frequência.

  7. Multiplicação do filtro de frequência pela imagem transformada.

  8. Troca de quadrantes para que a origem da imagem transformada volte para o canto superior esquerdo.`

  9. Remoção do padding da imagem (caso necessário).

  10. Visualização da imagem filtrada.

Nessa sequência de passos, o processo de filtragem inicial com a criação do filtro \$H(u,v)\$, tal que a imagem filtrada é dada por \$G(u,v) = H(u,v) \cdot F(u,v)\$, onde \$F(u,v)\$ é a transformada de Fourier da imagem de entrada e \$G(u,v)\$ é a transformada de Fourier da imagem filtrada. Esse produto de matrizes é realizado elemento a elemento, e é implementado no OpenCV pela função mulSpectrums().

Para compilar e executar o programa dftfilter.cpp, salve-o juntamente com o arquivo Makefile e a imagem biel.png em um diretório e execute a seguinte seqüência de comandos:

$ make dftfilter
$ ./dftfilter biel.png

A saída do programa dftfilter é mostrado na Figura 25.

dft filter
Figura 25. Resultado do programa dftfilter

10.1. Descrição do programa dftfilter.cpp

O trecho de código a seguir mostra a chamada da função de criação do filtro de frequência e a aplicação do filtro na imagem.

cv::Mat filter;
makeFilter(complexImage, filter);
cv::mulSpectrums(complexImage, filter, complexImage, 0);

A função makeFilter() é responsável por criar o filtro de frequência. Ela recebe a imagem transformada e a matriz que será preenchida com o filtro de frequência é retornada no segundo parâmetro, que é passado na forma de uma referência.

void makeFilter(const cv::Mat &image, cv::Mat &filter){
  cv::Mat_<float> filter2D(image.rows, image.cols);
  int centerX = image.cols / 2;
  int centerY = image.rows / 2;
  int radius = 20;

  for (int i = 0; i < image.rows; i++) {
    for (int j = 0; j < image.cols; j++) {
      if (pow(i - centerY, 2) + pow(j - centerX, 2) <= pow(radius, 2)) { (1)
        filter2D.at<float>(i, j) = 1;
      } else {
        filter2D.at<float>(i, j) = 0;
      }
    }
  }

  cv::Mat planes[] = {cv::Mat_<float>(filter2D),
                      cv::Mat::zeros(filter2D.size(), CV_32F)}; (2)
  cv::merge(planes, 2, filter); (3)
}
1 A função makefilter() cria um filtro ideal de tamanho igual ao da imagem. Do centro da matriz até uma distância de 20 pixels, o valor filtro é igual a 1. Fora desse raio, o valor do filtro é igual a 0. O tipo de dado usado para criar a matriz é float, pois é o tipo de dado usado para armazenar os valores da DFT.
2 Para criar o filtro de frequência, é necessário criar uma matriz com dois canais, um para a parte real e outro para a parte imaginária. Daí a criação do vetor de matrizes planes[] e…​
3 A chamada da função merge() para criar a matriz de dois canais.
 // calcula a DFT inversa
swapQuadrants(complexImage);
cv::idft(complexImage, complexImage);

cv::split(complexImage, planos);

// recorta a imagem filtrada para o tamanho original
// selecionando a regiao de interesse (roi)
cv::Rect roi(0, 0, image.cols, image.rows);
cv::Mat result = planos[0](roi);

A última parte do código mostra como recuperar a imagem filtrada. A transformada de Fourier inversa é calculada com a função idft(). A função split() divide a imagem multicanal em duas matrizes, uma para a parte real (planos[0]) e outra para a parte imaginária (planos[1]).

A imagem filtrada é obtida selecionando a região de interesse da imagem correspondente ao tamanho original da imagem de entrada usando um objeto da classe Rect para esse fim. A imagem filtrada é armazenada na variável result e posteriormente normalizada para exibição.

10.2. Exercícios

  • Utilizando o programa exemplos/dftfilter.cpp como referência, implemente o filtro homomórfico para melhorar imagens com iluminação irregular. Crie uma cena mal iluminada e ajuste os parâmetros do filtro homomórfico para corrigir a iluminação da melhor forma possível. Assuma que a imagem fornecida é em tons de cinza.

Parte III: Segmentação de imagens

11. Detecção de bordas com o algoritmo de Canny

O detector de bordas de Canny é sabidamente reconhecido como um dos mais rápidos e eficientes algoritmos para encontrar descontinuidades em uma imagem. Ele produz como resultado uma imagem binária contendo os pontos de borda obtidos a partir de uma imagem, para um conjunto de parâmetros de configuração.

Em linhas gerais, o algoritmo de Canny procura descobrir bordas situadas em máximos locais do gradiente de uma image, e pode ser sumarizado pelos seguintes passos:

  1. Convolução com o filtro Gaussiano, cálculo da magnitude e ângulo do gradiente.

  2. Afinação das cristas largas do gradiente.

    1. Classificação dos pontos quanto às orientações Horizontal, Vertical, \(+45^\text{o}\), e \(-45^\text{o}\) (intervalos de \(\pm 22.5^\text{o}\)).

    2. Para os vizinhos na orientação determinada para o pixel, verificar os seus gradientes.

    3. Supressão de não máximos: se o valor da magnitude do gradiente \(M(x,y)\) for inferior a pelo menos um de seus vizinhos, faça \(g_N(x,y)=0\); caso contrário, faça \(g_N(x,y) = M(x,y)\). A imagem \(g_N(x,y)\) é a imagem com supressão.

  3. Limiarização com histerese é usada para a quebra do contorno (borda tracejada).

    1. Dois limiares \(T_1\) e \(T_2\). \(T_1 > T_2\) são usados.

    2. Se o pixel é tal que \(g_N(x,y) \ge T_1\), é assumido como ponto de borda forte.

    3. Para os pixels restantes, aqueles em que \(g_N(x,y) \ge T_2\), são assumidos como ponto de borda fraco.

    4. Para todos os vizinhos dos pontos de borda fraco, procurar nos seus 8-vizinhos se há algum ponto de borda forte. Caso haja, este é marcado como parte da fronteira.

    5. Sugestão de Canny: \(T_H/T_L = 3/1\), ou \(T_H/T_L =2/1\)

Um exemplo de aplicação desse algoritmo na imagem da Figura 26 é mostrado na Figura 27. Observe que as bordas encontradas são bem localizadas e geralmente possuem espessura igual a 1.

biel
Figura 26. Exemplo para o detector de Canny
canny
Figura 27. Detecção de bordas usando o filtro de Canny

O programa que gerou essa imagem é mostrado na Listagem 12.

Listagem 12. canny.cpp
#include <iostream>
#include "opencv2/opencv.hpp"

int top_slider = 10;
int top_slider_max = 200;

char TrackbarName[50];

cv::Mat image, border;

void on_trackbar_canny(int, void*){
  cv::Canny(image, border, top_slider, 3*top_slider);
  cv::imshow("Canny", border);
}

int main(int argc, char**argv){
  image= cv::imread(argv[1], cv::IMREAD_GRAYSCALE);
  
  sprintf( TrackbarName, "Threshold inferior", top_slider_max );

  cv::namedWindow("Canny",1);
  cv::createTrackbar( TrackbarName, "Canny",
                &top_slider,
                top_slider_max,
                on_trackbar_canny );

  on_trackbar_canny(top_slider, 0 );

  cv::waitKey();
  cv::imwrite("cannyborders.png", border);
  return 0;
}

Para compilar e executar o programa canny.cpp, salve-o juntamente com os arquivo Makefile e a imagem biel.png em um diretório e execute a seguinte seqüência de comandos:

$ make canny
$ ./canny biel.png

O programa disponibilizará uma scrollbar que regula o valor do limiar \(T_1\). O valor do limiar \(T_2\) é determinado automaticamente usando a proporção \(T_1 = 3 T_1\). Ao ser finalizado - quando uma tecla é pressionada - o programa escreve a imagem de bordas no arquivo de nome cannyborders.png.

Valores diferentes para o limiar escolhido produzem imagens de bordas diferentes.

A função de destaque nesse programa exemplo é apenas a função Canny().

cv::Canny(image, border, top_slider, 3*top_slider);

Os dois primeiros argumentos indicam a imagem a ser processada, a matriz onde a imagem de bordas será escrita, e os limiares \(T_1\) e \(T_2\), neste caso representado pelas quantidades top_slider e 3*top_slider.

11.1. Canny e a arte com pontilhismo

O algoritmo de Canny de fato é útil para diversas aplicações em processamento de imagens e visão artificial. Informações de bordas podem ser usadas para melhorar algoritmos de segmentação automática ou para encontrar objetos em cenas e pontos de interesse.

Entretanto, nesta lição, a proposta de uso do algoritmo é para desenvolver arte digital. A ideia é usar uma imagem de referência para criar uma nova imagem usando efeitos artísticos pontilhistas.

O pontilhismo é uma técnica de desenho impressionista onde o quadro é pintado usando apenas pontos. Um dos artistas pioneiros nessa técnica foi George Seurat. Vários dos seus trabalhos podem ser vistos online no site georgesseurat.org.

Simular no computador um efeito pontilhista não é muito trabalhoso. Uma estratégia simples é utilizar uma imagem de referência e criar uma outra imagem desenhada usando pequenos círculos. Em suma, percorre-se a imagem de referência e para cada pixel, desenha-se um círculo com a mesma cor na posição correspondente na imagem pontilhista.

Efeitos pontilhistas interessantes podem ser criados com variantes simples dessa técnica. Exemplo: pular sequências de pixels na imagem de referência para dar a impressão de que os pontos estão separados na tela - isso é bastante comum na arte pontilhista. Outro efeito interessante é realizar deslocamentos aleatórios nos centros dos círculos, para que a imagem gerada permaneca menos artificial. Finalmente, é razoável percorrer a matriz de referência usando uma sequência aleatória, principalmente quando a técnica pontilhista realiza a sobreposição de círculos.

Um exemplo de imagem pontilhista é mostrada na Figura 28.

Pontos
Figura 28. Imagem pontilhista

O programa que gerou essa imagem é mostrado na Listagem 13.

Listagem 13. pontilhismo.cpp
#include <algorithm>
#include <cstdlib>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <opencv2/opencv.hpp>
#include <vector>

#define STEP 5
#define JITTER 3
#define RAIO 3

int main(int argc, char** argv) {
  std::vector<int> yrange;
  std::vector<int> xrange;

  cv::Mat image, frame, points;

  int width, height, gray;
  int x, y;

  image = cv::imread(argv[1], cv::IMREAD_GRAYSCALE);

  std::srand(std::time(0));

  if (image.empty()) {
    std::cout << "Could not open or find the image" << std::endl;
    return -1;
  }

  width = image.cols;
  height = image.rows;

  xrange.resize(height / STEP);
  yrange.resize(width / STEP);

  std::iota(xrange.begin(), xrange.end(), 0);
  std::iota(yrange.begin(), yrange.end(), 0);

  for (uint i = 0; i < xrange.size(); i++) {
    xrange[i] = xrange[i] * STEP + STEP / 2;
  }

  for (uint i = 0; i < yrange.size(); i++) {
    yrange[i] = yrange[i] * STEP + STEP / 2;
  }

  points = cv::Mat(height, width, CV_8U, cv::Scalar(255));

  std::random_shuffle(xrange.begin(), xrange.end());

  for (auto i : xrange) {
    std::random_shuffle(yrange.begin(), yrange.end());
    for (auto j : yrange) {
      x = i + std::rand() % (2 * JITTER) - JITTER + 1;
      y = j + std::rand() % (2 * JITTER) - JITTER + 1;
      gray = image.at<uchar>(x, y);
      cv::circle(points, cv::Point(y, x), RAIO, CV_RGB(gray, gray, gray),
                 cv::FILLED, cv::LINE_AA);
    }
  }
  cv::imwrite("pontos.jpg", points);
  return 0;
}

Para compilar e executar o programa pontilhismo.cpp, salve-o juntamente com o arquivo Makefile e a imagem biel.png em um diretório e execute a seguinte seqüência de comandos:

$ make pontilhismo
$ ./pontilhismo biel.png

11.2. Descrição do programa pontilhismo.cpp

O programa pontilhismo.cpp não introduz novas funcionalidades da biblioteca de programação OpenCV. Entretanto, algumas classes da STL, a biblioteca padrão de gabaritos do C++ estão presentes no código para facilitar a criação de alguns efeitos. Logo, é importante discorrer um pouco sobre seu uso no exemplo.

std::vector<int> yrange;
std::vector<int> xrange;

Define-se dois arrays de índices que servirão para identificar elementos da imagem de referência. Os tamanhos dos arrays xrange e yrange são determinados como frações da altura e da largura da imagem, respectivamente. Isso é feito para que na geração da imagem pontilhista, apenas alguns pontos sejam amostrados na imagem de referência, evitando sobrecarga visual.

A grandeza STEP define o passo usado para varrer a imagem de referência. No exemplo, usamos STEP igual a 5 pixels, ou seja, considerando as duas dimensões da imagem, apenas 1 em cada \(5 \times 5 = 25\) pixels de uma janela é usado para criar um círculo.

std::iota(xrange.begin(), xrange.end(), 0);
std::iota(yrange.begin(), yrange.end(), 0);

for(uint i=0; i<xrange.size(); i++){
  xrange[i]= xrange[i]*STEP+STEP/2;
}

for(uint i=0; i<yrange.size(); i++){
  yrange[i]= yrange[i]*STEP+STEP/2;
}

Os arrays xrange e yrange são preenchidos com valores sequenciais iniciando em 0 e, em seguida, esses valores recebem um ganho igual a STEP e um deslocamento STEP/2, para que o processo de amostragem na imagem de referência se dê no centro da janela.

std::random_shuffle(xrange.begin(), xrange.end());

A função random_shuffle() recebe como parâmetros 2 iteradores: uma para o início do array e outro para o final. Como resultado, a função embaralha aleatoriamente todos seus elementos. Se observado, esse processo é feito uma vez para o array de índices das linhas - xrange - e, para cada linha, embaralha-se o array de índices das colunas - yrange.

Os loops descritos por for(auto i : xrange) e for(auto j : yrange) são construções na especificação C++11 e servem para fazer as variáveis i e j assumirem, a cada passada no loop, os valores dos arrays xrange e yrange de forma consecutiva.

x = i+rand()%(2*JITTER)-JITTER+1;
y = j+rand()%(2*JITTER)-JITTER+1;

O valor das coordenadas do ponto cujo tom de cinza será amostrado na imagem de referência é determinado pela posição do centro da janela mais um deslocamento aleatório em ambas as direções. Esse deslocamento é determinado pela grandeza JITTER (igual a 3 pixels).

Variações das grandezas STEP e JITTER podem ser modificadas para uso em imagens de tamanhos diferentes.

cv::circle(points, cv::Point(y, x), RAIO, CV_RGB(gray, gray, gray),
                 cv::FILLED, cv::LINE_AA);

A função circle() é usada para traçar um círculo de raio especificado em um ponto determinado pelo usuário. O círculo é desenhado usando preenchimento sólido e, dada a presença do parâmetro cv::LINE_AA, este será desenhado usando técnicas de antialiasing. Assim, o círculo terá bordas não serrilhadas, produzindo um efeito visual agradável na imagem pontilhista.

11.3. Exercícios

  • Utilizando os programas exemplos/canny.cpp e exemplos/pontilhismo.cpp como referência, implemente um programa cannypoints.cpp. A idéia é usar as bordas produzidas pelo algoritmo de Canny para melhorar a qualidade da imagem pontilhista gerada. A forma como a informação de borda será usada é livre. Entretanto, são apresentadas algumas sugestões de técnicas que poderiam ser utilizadas:

    • Desenhar pontos grandes na imagem pontilhista básica;

    • Usar a posição dos pixels de borda encontrados pelo algoritmo de Canny para desenhar pontos nos respectivos locais na imagem gerada.

    • Experimente ir aumentando os limiares do algoritmo de Canny e, para cada novo par de limiares, desenhar círculos cada vez menores nas posições encontradas. A Figura 29 foi desenvolvida usando essa técnica.

  • Escolha uma imagem de seu gosto e aplique a técnica que você desenvolveu.

  • Descreva no seu relatório detalhes do procedimento usado para criar sua técnica pontilhista.

lenapontilhista
Figura 29. Pontilhismo aplicado à imagem Lena

12. Quantização vetorial com k-means

Algoritmos de quantização são um grupo de técnicas usadas para mapear os dados presentes em um conjunto grande em um conjunto menor de elementos. É normalmente usada para fins de compressão de dados. Quando um grande conjunto de pontos (vetores) é dividido em em grupos de tamanho menor, diz-se que tem uma quantização vetorial, onde cada grupo é representado por um centróide.

Dos vários algoritmos de quantização vetorial que podem ser encontrados na literatura, o k-means está entre os mais populares. É um algoritmo simples que particiona o espaço N-dimensional em células de Voronoi, onde cada célula é determinada por um centro. O conjunto de todos os pontos no espaço cuja distância para um dado centro é menor que para todos os outros centros define a célula.

O algoritmo k-means funciona conforme os seguintes passos:

  1. Escolha \$k\$ como o número de classes para os vetores \$\mathbf{x}_i\$ de \$N\$ amostras, \$i=1,2,\cdots,N\$.

  2. Escolha \$\mathbf{m}_1, \mathbf{m}_2,\cdots,\mathbf{m}_k\$ como aproximações iniciais para os centros das classes.

  3. Classifique cada amostra \$\mathbf{x}_i\$ usando, por exemplo, um classificador de distância mínima (distância euclideana).

  4. Recalcule as médias \$\mathbf{m}_j\$ usando o resultado do passo anterior.

  5. Se as novas médias são consistentes (não mudam consideravelmente), finalize o algoritmo. Caso contrário, recalcule os centros e refaça a classificação.

Algo que se percebe do algoritmo k-means é que cada execução leva a um resultado diferente do resultado anterior. Embora o algoritmo normalmente estabilize, algumas execuções podem criar aglomerações melhores que outras. Logo, é comum executar o algoritmo algumas vezes e verificar qual execução gera melhor compactação dos dados. Uma das medidas de compactação - a usada pelo OpenCV - verifica a soma dos quadrados das distâncias dos pontos da amostra para seus respectivos centros.

O programa de referência utilizado para essa tarefa, kmeans.cpp, é mostrado na Listagem Kmeans.

Listagem 14. kmeans.cpp
#include <cstdlib>
#include <opencv2/opencv.hpp>

int main(int argc, char** argv) {
  int nClusters = 8, nRodadas = 5;

  cv::Mat rotulos, centros;

  if (argc != 3) {
    std::cout << "kmeans entrada.jpg saida.jpg\n";
    exit(0);
  }

  cv::Mat img = cv::imread(argv[1], cv::IMREAD_COLOR);
  cv::Mat samples(img.rows * img.cols, 3, CV_32F);

  for (int y = 0; y < img.rows; y++) {
    for (int x = 0; x < img.cols; x++) {
      for (int z = 0; z < 3; z++) {
        samples.at<float>(y + x * img.rows, z) = img.at<cv::Vec3b>(y, x)[z];
      }
    }
  }

  cv::kmeans(samples, nClusters, rotulos,
             cv::TermCriteria(cv::TermCriteria::EPS | cv::TermCriteria::COUNT,
                              10000, 0.0001),
             nRodadas, cv::KMEANS_PP_CENTERS, centros);

  cv::Mat rotulada(img.size(), img.type());
  for (int y = 0; y < img.rows; y++) {
    for (int x = 0; x < img.cols; x++) {
      int indice = rotulos.at<int>(y + x * img.rows, 0);
      rotulada.at<cv::Vec3b>(y, x)[0] = (uchar)centros.at<float>(indice, 0);
      rotulada.at<cv::Vec3b>(y, x)[1] = (uchar)centros.at<float>(indice, 1);
      rotulada.at<cv::Vec3b>(y, x)[2] = (uchar)centros.at<float>(indice, 2);
    }
  }
  cv::imshow("kmeans", rotulada);
  cv::imwrite(argv[2], rotulada);
  cv::waitKey();
}

Para compilar e executar o programa kmeans.cpp, salve-o juntamente com o arquivo Makefile e a imagem sushi.jpg em um diretório e execute a seguinte seqüência de comandos:

$ make kmeans
$ ./kmeans sushi.jpg sushi-kmeans.jpg

A saída do programa kmeans é mostrado na Figura 30

Saida do programa kmeans
Figura 30. Saída do programa kmeans

12.1. Descrição do programa kmeans.cpp

O programa kmeans opera sobre a imagem fornecida como primeiro argumento de modo a reduzir a quantidade de cores presentes na mesma para um total de 6 cores (que pode ser ajustada pela variável nClusters).

cv::Mat samples(img.rows * img.cols, 3, CV_32F);

Uma matriz de amostras é criada para armazenar todas as cores dos pixels da imagem. É comum executar o k-means com uma amostra do espaço de entrada, mas utilizou-se a totalidade dos pixels imagem nesse exemplo.

A matriz samples possui um total de linhas igual ao total de pixels da imagem fornecida e apenas três colunas. Cada coluna é concebida para armazenar cada uma das componentes de cor (R, G e B) dos pixels.

samples.at<float>(y + x*img.rows, z) = img.at<cv::Vec3b>(y,x)[z];

A cópia pixel a pixel, componente a componente de cor é realizada da imagem de entrada para a matriz de amostras.

cv::kmeans(samples, nClusters, rotulos,
             cv::TermCriteria(cv::TermCriteria::EPS | cv::TermCriteria::COUNT,
                              10000, 0.0001),
             nRodadas, cv::KMEANS_PP_CENTERS, centros);

A matriz com as amostras samples deve conter em cada linha uma das amostras a ser processada pela função disponível pelo opencv. nClusters informa a quantidade de aglomerados que se deseja obter. A matriz rotulos é um objeto do tipo Mat preenchido com elementos do tipo int, onde cada elemento identifica a classe à qual pertence a amostra na matriz samples. No exemplo, um máximo de até 10000 iterações ou tolerância de 0.0001 devem ser atingidos para finalizar o algoritmo. O algoritmo é repetido por uma quantidade de vezes definida por nRodadas. A rodada que produz a menor soma de distâncias dos pontos para seus respectivos centros é escolhida como vencedora. Os centros do algoritmo são inicializados usando o algoritmo proposto por Arthur2007. Finalmente, as coordenadas dos centros são guardadas na matriz centros.

É importante perceber que tanto a matriz de amostras quanto a matriz com os centros é definida como float para realizar a execução do algoritmo. As aproximações geradas por matrizes inteiras levariam a resultados incorretos do k-means.

rotulada.at<cv::Vec3b>(y,x)[0] = (uchar) centros.at<float>(indice, 0);
rotulada.at<cv::Vec3b>(y,x)[1] = (uchar) centros.at<float>(indice, 1);
rotulada.at<cv::Vec3b>(y,x)[2] = (uchar) centros.at<float>(indice, 2);

Por fim, uma versão quantizada da imagem de entrada é composta usando os centros obtidos na execução do k-means.

12.2. Exercícios

  • Utilizando o programa kmeans.cpp como exemplo prepare um programa exemplo onde a execução do código se dê usando o parâmetro nRodadas=1 e inciar os centros de forma aleatória usando o parâmetro KMEANS_RANDOM_CENTERS ao invés de KMEANS_PP_CENTERS. Realize 10 rodadas diferentes do algoritmo e compare as imagens produzidas. Explique porque elas podem diferir tanto.

Parte IV: Outras Transformadas Matemáticas

13. Filtragem de forma com morfologia matemática

A filtragem de forma é uma técnica de processamento de imagens que visa corrigir imperfeições relacionadas com a forma de objetos que compõem, como por exemplo pequenas regiões. Ela é realizada através de operações morfológicas que atuam sobre a forma de objetos na imagem, modificando a propriedade dos pixels conforme propriedades de uma vizinhança selecionada. Assim como na operação de convolução a máscara utilizada desempenha um papel fundamental no resultado do processo, na morfologia, o efeito da filtragem é controlada por um conjunto denominado elemento estruturante. O elemento estruturante normalmente é uma matriz binária que define a forma e o tamanho da vizinhança que será utilizada para a filtragem.

A Figura 31 mostra um exemplo típico de uma imagem corrompiada pelo ruído de forma. Perceba que a figura contém várias linhas que não são desejadas, tanto permeando a região de fundo escuro quanto a região branca que representa o objeto.

Falhas de forma
Figura 31. Figura com falhas de forma

A filtragem de forma pode ser utilizada para corrigir esse problema usando o programa morfologia.cpp, que é mostrado na Listagem 15.

Listagem 15. morfologia.cpp
#include <iostream>
#include <opencv2/opencv.hpp>

int main(int argc, char** argv) {
  cv::Mat image, erosao, dilatacao, abertura, fechamento, abertfecha;
  cv::Mat str;

  if (argc != 2) {
    std::cout << "morfologia entrada saida\n";
  }

  image = cv::imread(argv[1], cv::IMREAD_UNCHANGED);

//  image = cv::imread(argv[1], -1);
  
  if(image.empty()) {
    std::cout << "Erro ao carregar a imagem: " << argv[1] << std::endl;
    return -1;
  }

  // elemento estruturante
  str = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));
  // erosao
  cv::erode(image, erosao, str);
  // dilatacao
  cv::dilate(image, dilatacao, str);
  // abertura
  cv::morphologyEx(image, abertura, cv::MORPH_OPEN, str);
  // fechamento
  cv::morphologyEx(image, fechamento, cv::MORPH_CLOSE, str);
  // abertura -> fechamento
  cv::morphologyEx(abertura, abertfecha, cv::MORPH_CLOSE, str);
  
  cv::Mat matArray[] = {erosao, dilatacao, abertura, fechamento, abertfecha};
  cv::hconcat(matArray, 5, image);

  cv::imshow("morfologia", image);

  cv::waitKey();
  return 0;
}

Para compilar e executar o programa morfologia.cpp, salve-o juntamente com o arquivo Makefile e a imagem morfoobjetos.png em um diretório e execute a seguinte seqüência de comandos:

$ make morfologia
$ ./morfologia morfoobjetos.png

A saída do programa morfologia é mostrado na Figura 32. Da esquerda para a direita são apresentadas as imagens resultantes das operações erosão, dilatação, abertura, fechamento e abertura seguida de fechamento, respectivamente.

Saída do programa morfologia
Figura 32. Saída do programa morfologia

13.1. Descrição do programa morfologia.cpp

O programa morfologia.cpp é um exemplo de aplicação da filtragem de forma. Ele recebe como parâmetro de entrada uma imagem e aplica as operações morfológicas de erosão, dilatação, abertura, fechamento e abertura seguida de fechamento. O programa utiliza a biblioteca OpenCV para carregar a imagem e exibir os resultados.

O primeiro passo da filtragem é criar o elemento estruturante que irá modelar as operações de filtragem morfológica.

str = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));

A função getStructuringElement() cria um elemento estruturante com a forma de um retângulo de tamanho \$3 \times 3\$, todos preenchidos com o valor 1 (o elemento é representado como um objeto do tipo MAT). Essa marcação 1s indica que o elemento estruturante irá atuar sobre todos os pixels da vizinhança. A função getStructuringElement também pode ser utilizada para criar elementos estruturantes com formas diferentes, como por exemplo um elemento estruturante com forma de cruz, elipse ou disco, preenchido dentro do retângulo que limita o tamanho do elemento.

cv::erode(image, erosao, str);

Realiza a erosão da imagem image pelo elemento estruturante str.

cv::dilate(image, dilatacao, str);

Realiza a dilatação da imagem image pelo elemento estruturante str.

cv::morphologyEx(image, abertura, cv::MORPH_OPEN, str);

Realiza a abertura da imagem image pelo elemento estruturante str. A abertura é uma operação morfológica que consiste na erosão seguida de dilatação. Perceba que, ao contrário da erosão e dilatação, não há uma função específica para realizar a abertura. Para realizar a abertura é necessário chamar a função morphologyEx() passando como parâmetro o valor MORPH_OPEN para o parâmetro op. A função morphologyEx() é uma função genérica que permite realizar algumas das operações morfológicas mais comuns, como erosão, dilatação, abertura, fechamento, top hat, black hat e a transformada hit-or-miss.

cv::morphologyEx(image, fechamento, cv::MORPH_CLOSE, str);

Realiza o fechamento da imagem image pelo elemento estruturante str.

cv::morphologyEx(abertura, abertfecha, cv::MORPH_CLOSE, str);

Realiza a abertura seguida de fechamento da imagem image pelo elemento estruturante str.

13.2. Exercícios

  • Um sistema de captura de imagens precisa realizar o reconhecimento de carateres de um visor de segmentos para uma aplicação industrial. O visor mostra caracteres como estes apresentados na Figura 33.

digitos
Figura 33. Caracteres do visor

Ocorre que o software de reconhecimento de padrões apresenta dificuldades de reconhecer os dígitos em virtude da separação existente entre os segmentos do visor. Idealmente, o software deveria reconhecer os dígitos como na Figura 34.

digitos filtered
Figura 34. Caracteres ideais para o reconhecimento

Usando o programa morfologia.cpp como referência, crie um programa que resolva o problema da pré-filtragem de forma para reconhecimento dos caracteres usando operações morfológicas. Você poderá usar as imagens digitos-1.png, digitos-2.png, digitos-3.png, digitos-4.png e digitos-5.png para testar seu programa. Cuidado para deixar o ponto decimal separado dos demais dígitos para evitar um reconhecimento errado do número no visor.